From e25269e1b3455047be104ee1ecba877de1223356 Mon Sep 17 00:00:00 2001 From: Sajna Shetty <42166139+Sajna-Shetty@users.noreply.github.com> Date: Mon, 10 Jun 2019 16:11:48 +0530 Subject: [PATCH] Adding dell ome device_info module (#53438) * Adding device_facts module for contribution * changes added for pylint error * Updated code to solve ansible-test compile error * Changes to avoide comile error added * Review Comments changes update * Avoided blank line * pylint error changes * Removed ansible_facts return in error case * Updated description * modules renamed * changing from ansible_facts to device_info * avoide pep8 error * Updated sample output * version changed to 2.9 * Changed Copyright license to BSD * Changed 3-clause BSD license to 2-clause BSD * Added unit test support for ome_device_info * version change * removed pylint error in unit test modules * Avoid Sanity error for unit test modules * updated version --- .../remote_management/dellemc/ome.py | 181 ++++++++ .../remote_management/dellemc/ome/__init__.py | 0 .../dellemc/ome/ome_device_info.py | 418 ++++++++++++++++++ .../remote_management/__init__.py | 0 .../remote_management/dellemc/__init__.py | 0 .../remote_management/dellemc/test_ome.py | 78 ++++ .../remote_management/dellemc/__init__.py | 0 .../remote_management/dellemc/ome/__init__.py | 0 .../dellemc/ome/test_ome_device_info.py | 189 ++++++++ 9 files changed, 866 insertions(+) create mode 100644 lib/ansible/module_utils/remote_management/dellemc/ome.py create mode 100644 lib/ansible/modules/remote_management/dellemc/ome/__init__.py create mode 100644 lib/ansible/modules/remote_management/dellemc/ome/ome_device_info.py create mode 100644 test/units/module_utils/remote_management/__init__.py create mode 100644 test/units/module_utils/remote_management/dellemc/__init__.py create mode 100644 test/units/module_utils/remote_management/dellemc/test_ome.py create mode 100644 test/units/modules/remote_management/dellemc/__init__.py create mode 100644 test/units/modules/remote_management/dellemc/ome/__init__.py create mode 100644 test/units/modules/remote_management/dellemc/ome/test_ome_device_info.py diff --git a/lib/ansible/module_utils/remote_management/dellemc/ome.py b/lib/ansible/module_utils/remote_management/dellemc/ome.py new file mode 100644 index 00000000000..c387e2a1640 --- /dev/null +++ b/lib/ansible/module_utils/remote_management/dellemc/ome.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- + +# Dell EMC OpenManage Ansible Modules +# Version 1.3 +# Copyright (C) 2019 Dell Inc. or its subsidiaries. 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 __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import json +from ansible.module_utils.urls import open_url, ConnectionError, SSLValidationError +from ansible.module_utils.six.moves.urllib.error import URLError, HTTPError +from ansible.module_utils.six.moves.urllib.parse import urlencode + +SESSION_RESOURCE_COLLECTION = { + "SESSION": "SessionService/Sessions", + "SESSION_ID": "SessionService/Sessions('{Id}')", +} + + +class OpenURLResponse(object): + """Handles HTTPResponse""" + + def __init__(self, resp): + self.body = None + self.resp = resp + if self.resp: + self.body = self.resp.read() + + @property + def json_data(self): + try: + return json.loads(self.body) + except ValueError: + raise ValueError("Unable to parse json") + + @property + def status_code(self): + return self.resp.getcode() + + @property + def success(self): + return self.status_code in (200, 201, 202, 204) + + @property + def token_header(self): + return self.resp.headers.get('X-Auth-Token') + + +class RestOME(object): + """Handles OME API requests""" + + def __init__(self, module_params=None, req_session=False): + self.module_params = module_params + self.hostname = self.module_params["hostname"] + self.username = self.module_params["username"] + self.password = self.module_params["password"] + self.port = self.module_params["port"] + self.req_session = req_session + self.session_id = None + self.protocol = 'https' + self._headers = {'Content-Type': 'application/json', 'Accept': 'application/json'} + + def _get_base_url(self): + """builds base url""" + return '{0}://{1}:{2}/api'.format(self.protocol, self.hostname, self.port) + + def _build_url(self, path, query_param=None): + """builds complete url""" + url = path + base_uri = self._get_base_url() + if path: + url = '{0}/{1}'.format(base_uri, path) + if query_param: + url += "?{0}".format(urlencode(query_param)) + return url + + def _url_common_args_spec(self, method, api_timeout, headers=None): + """Creates an argument common spec""" + req_header = self._headers + if headers: + req_header.update(headers) + url_kwargs = { + "method": method, + "validate_certs": False, + "use_proxy": True, + "headers": req_header, + "timeout": api_timeout, + "follow_redirects": 'all', + } + return url_kwargs + + def _args_without_session(self, method, api_timeout=30, headers=None): + """Creates an argument spec in case of basic authentication""" + req_header = self._headers + if headers: + req_header.update(headers) + url_kwargs = self._url_common_args_spec(method, api_timeout, headers=headers) + url_kwargs["url_username"] = self.username + url_kwargs["url_password"] = self.password + url_kwargs["force_basic_auth"] = True + return url_kwargs + + def _args_with_session(self, method, api_timeout=30, headers=None): + """Creates an argument spec, in case of authentication with session""" + url_kwargs = self._url_common_args_spec(method, api_timeout, headers=headers) + url_kwargs["force_basic_auth"] = False + return url_kwargs + + def invoke_request(self, method, path, data=None, query_param=None, headers=None, + api_timeout=30, dump=True): + """ + Sends a request via open_url + Returns :class:`OpenURLResponse` object. + :arg method: HTTP verb to use for the request + :arg path: path to request without query parameter + :arg data: (optional) Payload to send with the request + :arg query_param: (optional) Dictionary of query parameter to send with request + :arg headers: (optional) Dictionary of HTTP Headers to send with the + request + :arg api_timeout: (optional) How long to wait for the server to send + data before giving up + :arg dump: (Optional) boolean value for dumping payload data. + :returns: OpenURLResponse + """ + try: + if 'X-Auth-Token' in self._headers: + url_kwargs = self._args_with_session(method, api_timeout, headers=headers) + else: + url_kwargs = self._args_without_session(method, api_timeout, headers=headers) + if data and dump: + data = json.dumps(data) + url = self._build_url(path, query_param=query_param) + resp = open_url(url, data=data, **url_kwargs) + resp_data = OpenURLResponse(resp) + except (HTTPError, URLError, SSLValidationError, ConnectionError) as err: + raise err + return resp_data + + def __enter__(self): + """Creates sessions by passing it to header""" + if self.req_session: + payload = {'UserName': self.username, + 'Password': self.password, + 'SessionType': 'API', } + path = SESSION_RESOURCE_COLLECTION["SESSION"] + resp = self.invoke_request('POST', path, data=payload) + if resp and resp.success: + self.session_id = resp.json_data.get("Id") + self._headers["X-Auth-Token"] = resp.token_header + else: + msg = "Could not create the session" + raise ConnectionError(msg) + return self + + def __exit__(self, exc_type, exc_value, traceback): + """Deletes a session id, which is in use for request""" + if self.session_id: + path = SESSION_RESOURCE_COLLECTION["SESSION_ID"].format(Id=self.session_id) + self.invoke_request('DELETE', path) + return False diff --git a/lib/ansible/modules/remote_management/dellemc/ome/__init__.py b/lib/ansible/modules/remote_management/dellemc/ome/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/lib/ansible/modules/remote_management/dellemc/ome/ome_device_info.py b/lib/ansible/modules/remote_management/dellemc/ome/ome_device_info.py new file mode 100644 index 00000000000..ba43605ed62 --- /dev/null +++ b/lib/ansible/modules/remote_management/dellemc/ome/ome_device_info.py @@ -0,0 +1,418 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# +# Dell EMC OpenManage Ansible Modules +# Version 1.2 +# Copyright (C) 2019 Dell Inc. + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# All rights reserved. Dell, EMC, and other trademarks are trademarks of Dell Inc. or its subsidiaries. +# Other trademarks may be trademarks of their respective owners. +# + + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: ome_device_info +short_description: Retrieves the information about Device. +version_added: "2.9" +description: + - This module retrieves the list of all devices information with the exhaustive inventory of each + device. +options: + hostname: + description: + - Target IP Address or hostname. + type: str + required: True + username: + description: + - Target username. + type: str + required: True + password: + description: + - Target user password. + type: str + required: True + port: + description: + - Target HTTPS port. + type: int + default: 443 + fact_subset: + description: + - C(basic_inventory) returns the list of the devices. + - C(detailed_inventory) returns the inventory details of specified devices. + - C(subsystem_health) returns the health status of specified devices. + type: str + choices: [basic_inventory, detailed_inventory, subsystem_health ] + default: basic_inventory + system_query_options: + description: + - I(system_query_options) applicable for the choices of the fact_subset. Either I(device_id) or I(device_service_tag) + is mandatory for C(detailed_inventory) and C(subsystem_health) or both can be applicable. + type: dict + suboptions: + device_id: + description: + - A list of unique identifier is applicable + for C(detailed_inventory) and C(subsystem_health). + type: list + device_service_tag: + description: + - A list of service tags are applicable for C(detailed_inventory) + and C(subsystem_health). + type: list + inventory_type: + description: + - For C(detailed_inventory), it returns details of the specified inventory type. + type: str + filter: + description: + - For C(basic_inventory), it filters the collection of devices. + I(filter) query format should be aligned with OData standards. + type: str + +requirements: + - "python >= 2.7.5" +author: "Sajna Shetty(@Sajna-Shetty)" +''' + +EXAMPLES = """ +--- +- name: Retrieve basic inventory of all devices. + ome_device_info: + hostname: "192.168.0.1" + username: "username" + password: "password" + +- name: Retrieve basic inventory for devices identified by IDs 33333 or 11111 using filtering. + ome_device_info: + hostname: "192.168.0.1" + username: "username" + password: "password" + fact_subset: "basic_inventory" + system_query_options: + filter: "Id eq 33333 or Id eq 11111" + +- name: Retrieve inventory details of specified devices identified by IDs 11111 and 22222. + ome_device_info: + hostname: "192.168.0.1" + username: "username" + password: "password" + fact_subset: "detailed_inventory" + system_query_options: + device_id: + - 11111 + - 22222 + +- name: Retrieve inventory details of specified devices identified by service tags MXL1234 and MXL4567. + ome_device_info: + hostname: "192.168.0.1" + username: "username" + password: "password" + fact_subset: "detailed_inventory" + system_query_options: + device_service_tag: + - MXL1234 + - MXL4567 + +- name: Retrieve details of specified inventory type of specified devices identified by ID and service tags. + ome_device_info: + hostname: "192.168.0.1" + username: "username" + password: "password" + fact_subset: "detailed_inventory" + system_query_options: + device_id: + - 11111 + device_service_tag: + - MXL1234 + - MXL4567 + inventory_type: "serverDeviceCards" + +- name: Retrieve subsystem health of specified devices identified by service tags. + ome_device_info: + hostname: "192.168.0.1" + username: "username" + password: "password" + fact_subset: "subsystem_health" + system_query_options: + device_service_tag: + - MXL1234 + - MXL4567 + +""" + +RETURN = ''' +--- +msg: + type: str + description: Over all device information status. + returned: on error + sample: "Failed to fetch the device information" +device_info: + type: dict + description: Returns the information collected from the Device. + returned: success + sample: { + "value": [ + { + "Actions": null, + "AssetTag": null, + "ChassisServiceTag": null, + "ConnectionState": true, + "DeviceManagement": [ + { + "DnsName": "dnsname.host.com", + "InstrumentationName": "MX-12345", + "MacAddress": "11:10:11:10:11:10", + "ManagementId": 12345, + "ManagementProfile": [ + { + "HasCreds": 0, + "ManagementId": 12345, + "ManagementProfileId": 12345, + "ManagementURL": "https://192.168.0.1:443", + "Status": 1000, + "StatusDateTime": "2019-01-21 06:30:08.501" + } + ], + "ManagementType": 2, + "NetworkAddress": "192.168.0.1" + } + ], + "DeviceName": "MX-0003I", + "DeviceServiceTag": "MXL1234", + "DeviceSubscription": null, + "LastInventoryTime": "2019-01-21 06:30:08.501", + "LastStatusTime": "2019-01-21 06:30:02.492", + "ManagedState": 3000, + "Model": "PowerEdge MX7000", + "PowerState": 17, + "SlotConfiguration": {}, + "Status": 4000, + "SystemId": 2031, + "Type": 2000 + } + ] + } +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.remote_management.dellemc.ome import RestOME +from ansible.module_utils.six.moves.urllib.error import URLError, HTTPError +from ansible.module_utils.urls import ConnectionError, SSLValidationError + +DEVICES_INVENTORY_DETAILS = "detailed_inventory" +DEVICES_SUBSYSTEM_HEALTH = "subsystem_health" +DEVICES_INVENTORY_TYPE = "inventory_type" +DEVICE_LIST = "basic_inventory" +DESC_HTTP_ERROR = "HTTP Error 404: Not Found" +device_fact_error_report = {} + +DEVICE_RESOURCE_COLLECTION = { + DEVICE_LIST: {"resource": "DeviceService/Devices"}, + DEVICES_INVENTORY_DETAILS: {"resource": "DeviceService/Devices({Id})/InventoryDetails"}, + DEVICES_INVENTORY_TYPE: {"resource": "DeviceService/Devices({Id})/InventoryDetails('{InventoryType}')"}, + DEVICES_SUBSYSTEM_HEALTH: {"resource": "DeviceService/Devices({Id})/SubSystemHealth"}, +} + + +def _get_device_id_from_service_tags(service_tags, rest_obj): + """ + Get device ids from device service tag + Returns :dict : device_id to service_tag map + :arg service_tags: service tag + :arg rest_obj: RestOME class object in case of request with session. + :returns: dict eg: {1345:"MXL1245"} + """ + try: + path = DEVICE_RESOURCE_COLLECTION[DEVICE_LIST]["resource"] + resp = rest_obj.invoke_request('GET', path) + if resp.success: + devices_list = resp.json_data["value"] + service_tag_dict = {} + for item in devices_list: + if item["DeviceServiceTag"] in service_tags: + service_tag_dict.update({item["Id"]: item["DeviceServiceTag"]}) + available_service_tags = service_tag_dict.values() + not_available_service_tag = list(set(service_tags) - set(available_service_tags)) + device_fact_error_report.update(dict((tag, DESC_HTTP_ERROR) for tag in not_available_service_tag)) + else: + raise ValueError(resp.json_data) + except (URLError, HTTPError, SSLValidationError, ConnectionError, TypeError, ValueError) as err: + raise err + return service_tag_dict + + +def is_int(val): + """check when device_id numeric represented value is int""" + try: + int(val) + return True + except ValueError: + return False + + +def _check_duplicate_device_id(device_id_list, service_tag_dict): + """If service_tag is duplicate of device_id, then updates the message as Duplicate report + :arg1: device_id_list : list of device_id + :arg2: service_tag_id_dict: dictionary of device_id to service tag map""" + if device_id_list: + device_id_represents_int = [int(device_id) for device_id in device_id_list if device_id and is_int(device_id)] + common_val = list(set(device_id_represents_int) & set(service_tag_dict.keys())) + for device_id in common_val: + device_fact_error_report.update( + {service_tag_dict[device_id]: "Duplicate report of device_id: {0}".format(device_id)}) + del service_tag_dict[device_id] + + +def _get_device_identifier_map(module_params, rest_obj): + """ + Builds the identifiers mapping + :returns: the dict of device_id to server_tag map + eg: {"device_id":{1234: None},"device_service_tag":{1345:"MXL1234"}}""" + system_query_options_param = module_params.get("system_query_options") + device_id_service_tag_dict = {} + if system_query_options_param is not None: + device_id_list = system_query_options_param.get("device_id") + device_service_tag_list = system_query_options_param.get("device_service_tag") + if device_id_list: + device_id_dict = dict((device_id, None) for device_id in list(set(device_id_list))) + device_id_service_tag_dict["device_id"] = device_id_dict + if device_service_tag_list: + service_tag_dict = _get_device_id_from_service_tags(device_service_tag_list, + rest_obj) + + _check_duplicate_device_id(device_id_list, service_tag_dict) + device_id_service_tag_dict["device_service_tag"] = service_tag_dict + return device_id_service_tag_dict + + +def _get_query_parameters(module_params): + """ + Builds query parameter + :returns: dictionary, which is applicable builds the query format + eg : {"$filter":"Type eq 2000"} + """ + system_query_options_param = module_params.get("system_query_options") + query_parameter = None + if system_query_options_param: + filter_by_val = system_query_options_param.get("filter") + if filter_by_val: + query_parameter = {"$filter": filter_by_val} + return query_parameter + + +def _get_resource_parameters(module_params, rest_obj): + """ + Identifies the resource path by different states + :returns: dictionary containing identifier with respective resource path + eg:{"device_id":{1234:""DeviceService/Devices(1234)/InventoryDetails"}, + "device_service_tag":{"MXL1234":"DeviceService/Devices(1345)/InventoryDetails"}} + """ + fact_subset = module_params["fact_subset"] + path_dict = {} + if fact_subset != DEVICE_LIST: + inventory_type = None + device_id_service_tag_dict = _get_device_identifier_map(module_params, rest_obj) + if fact_subset == DEVICES_INVENTORY_DETAILS: + system_query_options = module_params.get("system_query_options") + inventory_type = system_query_options.get(DEVICES_INVENTORY_TYPE) + path_identifier = DEVICES_INVENTORY_TYPE if inventory_type else fact_subset + for identifier_type, identifier_dict in device_id_service_tag_dict.items(): + path_dict[identifier_type] = {} + for device_id, service_tag in identifier_dict.items(): + key_identifier = service_tag if identifier_type == "device_service_tag" else device_id + path = DEVICE_RESOURCE_COLLECTION[path_identifier]["resource"].format(Id=device_id, + InventoryType=inventory_type) + path_dict[identifier_type].update({key_identifier: path}) + else: + path_dict.update({DEVICE_LIST: DEVICE_RESOURCE_COLLECTION[DEVICE_LIST]["resource"]}) + return path_dict + + +def _check_mutually_inclusive_arguments(val, module_params, required_args): + """" + Throws error if arguments detailed_inventory, subsystem_health + not exists with qualifier device_id or device_service_tag""" + system_query_options_param = module_params.get("system_query_options") + if system_query_options_param is None or (system_query_options_param is not None and not any( + system_query_options_param.get(qualifier) for qualifier in required_args)): + raise ValueError("One of the following {0} is required for {1}".format(required_args, val)) + + +def _validate_inputs(module_params): + """validates input parameters""" + fact_subset = module_params["fact_subset"] + if fact_subset != "basic_inventory": + _check_mutually_inclusive_arguments(fact_subset, module_params, ["device_id", "device_service_tag"]) + + +def main(): + system_query_options = {"type": 'dict', "required": False, "options": { + "device_id": {"type": 'list'}, + "device_service_tag": {"type": 'list'}, + "inventory_type": {"type": 'str'}, + "filter": {"type": 'str', "required": False}, + }} + + module = AnsibleModule( + argument_spec={ + "hostname": {"required": True, "type": 'str'}, + "username": {"required": True, "type": 'str'}, + "password": {"required": True, "type": 'str', "no_log": True}, + "port": {"required": False, "default": 443, "type": 'int'}, + "fact_subset": {"required": False, "default": "basic_inventory", + "choices": ['basic_inventory', 'detailed_inventory', 'subsystem_health']}, + "system_query_options": system_query_options, + }, + required_if=[['fact_subset', 'detailed_inventory', ['system_query_options']], + ['fact_subset', 'subsystem_health', ['system_query_options']], ], + supports_check_mode=False) + + try: + _validate_inputs(module.params) + with RestOME(module.params, req_session=True) as rest_obj: + device_facts = _get_resource_parameters(module.params, rest_obj) + resp_status = [] + if device_facts.get("basic_inventory"): + query_param = _get_query_parameters(module.params) + resp = rest_obj.invoke_request('GET', device_facts["basic_inventory"], query_param=query_param) + device_facts = resp.json_data + resp_status.append(resp.status_code) + else: + for identifier_type, path_dict_map in device_facts.items(): + for identifier, path in path_dict_map.items(): + try: + resp = rest_obj.invoke_request('GET', path) + data = resp.json_data + resp_status.append(resp.status_code) + except HTTPError as err: + data = str(err) + path_dict_map[identifier] = data + if any(device_fact_error_report): + if "device_service_tag" in device_facts: + device_facts["device_service_tag"].update(device_fact_error_report) + else: + device_facts["device_service_tag"] = device_fact_error_report + if 200 in resp_status: + module.exit_json(device_info=device_facts) + else: + module.fail_json(msg="Failed to fetch the device information") + except (URLError, HTTPError, SSLValidationError, ConnectionError, TypeError, ValueError) as err: + module.fail_json(msg=str(err)) + + +if __name__ == '__main__': + main() diff --git a/test/units/module_utils/remote_management/__init__.py b/test/units/module_utils/remote_management/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/units/module_utils/remote_management/dellemc/__init__.py b/test/units/module_utils/remote_management/dellemc/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/units/module_utils/remote_management/dellemc/test_ome.py b/test/units/module_utils/remote_management/dellemc/test_ome.py new file mode 100644 index 00000000000..c79e54c841d --- /dev/null +++ b/test/units/module_utils/remote_management/dellemc/test_ome.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- + +# +# Dell EMC OpenManage Ansible Modules +# Version 2.0 +# Copyright (C) 2019 Dell Inc. + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# All rights reserved. Dell, EMC, and other trademarks are trademarks of Dell Inc. or its subsidiaries. +# Other trademarks may be trademarks of their respective owners. +# + +from __future__ import absolute_import + +import pytest +from ansible.module_utils.urls import ConnectionError, SSLValidationError +from ansible.module_utils.six.moves.urllib.error import URLError, HTTPError +from ansible.module_utils.remote_management.dellemc.ome import RestOME +from units.compat.mock import MagicMock +import json + + +class TestRestOME(object): + @pytest.fixture + def mock_response(self): + mock_response = MagicMock() + mock_response.getcode.return_value = 200 + mock_response.headers = mock_response.getheaders.return_value = {'X-Auth-Token': 'token_id'} + mock_response.read.return_value = json.dumps({"value": "data"}) + return mock_response + + def test_invoke_request_with_session(self, mock_response, mocker): + mocker.patch('ansible.module_utils.remote_management.dellemc.ome.open_url', + return_value=mock_response) + module_params = {'hostname': '192.168.0.1', 'username': 'username', + 'password': 'password', "port": 443} + req_session = True + with RestOME(module_params, req_session) as obj: + response = obj.invoke_request("/testpath", "GET") + assert response.status_code == 200 + assert response.json_data == {"value": "data"} + assert response.success is True + + def test_invoke_request_without_session(self, mock_response, mocker): + mocker.patch('ansible.module_utils.remote_management.dellemc.ome.open_url', + return_value=mock_response) + module_params = {'hostname': '192.168.0.1', 'username': 'username', + 'password': 'password', "port": 443} + req_session = False + with RestOME(module_params, req_session) as obj: + response = obj.invoke_request("/testpath", "GET") + assert response.status_code == 200 + assert response.json_data == {"value": "data"} + assert response.success is True + + @pytest.mark.parametrize("exc", [URLError, SSLValidationError, ConnectionError]) + def test_invoke_request_error_case_handling(self, exc, mock_response, mocker): + open_url_mock = mocker.patch('ansible.module_utils.remote_management.dellemc.ome.open_url', + return_value=mock_response) + open_url_mock.side_effect = exc("test") + module_params = {'hostname': '192.168.0.1', 'username': 'username', + 'password': 'password', "port": 443} + req_session = False + with pytest.raises(exc) as e: + with RestOME(module_params, req_session) as obj: + obj.invoke_request("/testpath", "GET") + + def test_invoke_request_http_error_handling(self, mock_response, mocker): + open_url_mock = mocker.patch('ansible.module_utils.remote_management.dellemc.ome.open_url', + return_value=mock_response) + open_url_mock.side_effect = HTTPError('http://testhost.com/', 400, + 'Bad Request Error', {}, None) + module_params = {'hostname': '192.168.0.1', 'username': 'username', + 'password': 'password', "port": 443} + req_session = False + with pytest.raises(HTTPError) as e: + with RestOME(module_params, req_session) as obj: + obj.invoke_request("/testpath", "GET") diff --git a/test/units/modules/remote_management/dellemc/__init__.py b/test/units/modules/remote_management/dellemc/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/units/modules/remote_management/dellemc/ome/__init__.py b/test/units/modules/remote_management/dellemc/ome/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/units/modules/remote_management/dellemc/ome/test_ome_device_info.py b/test/units/modules/remote_management/dellemc/ome/test_ome_device_info.py new file mode 100644 index 00000000000..ab435b382db --- /dev/null +++ b/test/units/modules/remote_management/dellemc/ome/test_ome_device_info.py @@ -0,0 +1,189 @@ +# -*- coding: utf-8 -*- + +# +# Dell EMC OpenManage Ansible Modules +# Version 2.0 +# Copyright (C) 2019 Dell Inc. + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# All rights reserved. Dell, EMC, and other trademarks are trademarks of Dell Inc. or its subsidiaries. +# Other trademarks may be trademarks of their respective owners. +# + +from __future__ import absolute_import + +import pytest +from units.modules.utils import set_module_args, exit_json, \ + fail_json, AnsibleFailJson, AnsibleExitJson +from ansible.module_utils import basic +from ansible.modules.remote_management.dellemc.ome import ome_device_info +from ansible.module_utils.six.moves.urllib.error import HTTPError + +default_args = {'hostname': '192.168.0.1', 'username': 'username', 'password': 'password'} +resource_basic_inventory = {"basic_inventory": "DeviceService/Devices"} +resource_detailed_inventory = {"detailed_inventory:": {"device_id": {1234: None}, + "device_service_tag": {1345: "MXL1234"}}} + + +class TestOmeDeviceInfo(object): + module = ome_device_info + + @pytest.fixture(autouse=True) + def module_mock(self, mocker): + return mocker.patch.multiple(basic.AnsibleModule, exit_json=exit_json, fail_json=fail_json) + + @pytest.fixture + def connection_mock(self, mocker): + connection_class_mock = mocker.patch('ansible.modules.remote_management.dellemc.ome.ome_device_info.RestOME') + return connection_class_mock.return_value + + @pytest.fixture + def response_mock(self, mocker): + response_class_mock = mocker.patch('ansible.module_utils.remote_management.dellemc.ome.OpenURLResponse') + return response_class_mock + + @pytest.fixture + def validate_inputs_mock(self, mocker): + response_class_mock = mocker.patch('ansible.modules.remote_management.dellemc.ome.ome_device_info._validate_inputs') + response_class_mock.return_value = None + + @pytest.fixture + def get_device_identifier_map_mock(self, mocker): + response_class_mock = mocker.patch('ansible.modules.remote_management.dellemc.ome.ome_device_info._get_device_identifier_map') + response_class_mock.return_value = resource_detailed_inventory + return response_class_mock.return_value + + @pytest.fixture + def get_resource_parameters_mock(self, mocker): + response_class_mock = mocker.patch('ansible.modules.remote_management.dellemc.ome.ome_device_info._get_resource_parameters') + return response_class_mock + + def test_main_basic_inventory_success_case(self, module_mock, validate_inputs_mock, connection_mock, get_resource_parameters_mock, response_mock): + get_resource_parameters_mock.return_value = resource_basic_inventory + connection_mock.__enter__.return_value = connection_mock + connection_mock.invoke_request.return_value = response_mock + response_mock.json_data = {"value": [{"device_id1": "details", "device_id2": "details"}]} + response_mock.status_code = 200 + result = self._run_module(default_args) + assert result['changed'] is False + assert 'device_info' in result + + def test_main_basic_inventory_failure_case(self, module_mock, validate_inputs_mock, connection_mock, get_resource_parameters_mock, response_mock): + get_resource_parameters_mock.return_value = resource_basic_inventory + connection_mock.__enter__.return_value = connection_mock + connection_mock.invoke_request.return_value = response_mock + response_mock.status_code = 500 + result = self._run_module_with_fail_json(default_args) + assert result['msg'] == 'Failed to fetch the device information' + + def test_main_detailed_inventory_success_case(self, module_mock, validate_inputs_mock, connection_mock, get_resource_parameters_mock, response_mock): + default_args.update({"fact_subset": "detailed_inventory", "system_query_options": {"device_id": [1234], "device_service_tag": ["MXL1234"]}}) + detailed_inventory = {"detailed_inventory:": {"device_id": {1234: "DeviceService/Devices(1234)/InventoryDetails"}, + "device_service_tag": {"MXL1234": "DeviceService/Devices(4321)/InventoryDetails"}}} + get_resource_parameters_mock.return_value = detailed_inventory + connection_mock.__enter__.return_value = connection_mock + connection_mock.invoke_request.return_value = response_mock + response_mock.json_data = {"value": [{"device_id": {"1234": "details"}}, {"device_service_tag": {"MXL1234": "details"}}]} + response_mock.status_code = 200 + result = self._run_module(default_args) + assert result['changed'] is False + assert 'device_info' in result + + def test_main_HTTPError_error_case(self, module_mock, validate_inputs_mock, connection_mock, get_resource_parameters_mock, response_mock): + get_resource_parameters_mock.return_value = resource_basic_inventory + connection_mock.__enter__.return_value = connection_mock + connection_mock.invoke_request.side_effect = HTTPError('http://testhost.com', 400, '', {}, None) + response_mock.json_data = {"value": [{"device_id1": "details", "device_id2": "details"}]} + response_mock.status_code = 400 + result = self._run_module_with_fail_json(default_args) + assert 'device_info' not in result + assert result['failed'] is True + + @pytest.mark.parametrize("fact_subset, mutually_exclusive_call", [("basic_inventory", False), ("detailed_inventory", True)]) + def test_validate_inputs(self, fact_subset, mutually_exclusive_call, mocker): + module_params = {"fact_subset": fact_subset} + check_mutually_inclusive_arguments_mock = mocker.patch( + 'ansible.modules.remote_management.dellemc.ome.ome_device_info._check_mutually_inclusive_arguments') + check_mutually_inclusive_arguments_mock.return_value = None + self.module._validate_inputs(module_params) + if mutually_exclusive_call: + check_mutually_inclusive_arguments_mock.assert_called() + else: + check_mutually_inclusive_arguments_mock.assert_not_called() + check_mutually_inclusive_arguments_mock.reset_mock() + + system_query_options_params = [{"system_query_options": None}, {"system_query_options": {"device_id": None}}, + {"system_query_options": {"device_service_tag": None}}] + + @pytest.mark.parametrize("system_query_options_params", system_query_options_params) + def test_check_mutually_inclusive_arguments(self, system_query_options_params): + module_params = {"fact_subset": "subsystem_health"} + required_args = ["device_id", "device_service_tag"] + module_params.update(system_query_options_params) + with pytest.raises(ValueError) as ex: + self.module._check_mutually_inclusive_arguments(module_params["fact_subset"], module_params, ["device_id", "device_service_tag"]) + assert "One of the following {0} is required for {1}".format(required_args, module_params["fact_subset"]) == str(ex.value) + + params = [{"fact_subset": "basic_inventory", "system_query_options": {"device_id": [1234]}}, + {"fact_subset": "subsystem_health", "system_query_options": {"device_service_tag": ["MXL1234"]}}, + {"fact_subset": "detailed_inventory", "system_query_options": {"device_id": [1234], "inventory_type": "serverDeviceCards"}}] + + @pytest.mark.parametrize("module_params", params) + def test_get_resource_parameters(self, module_params, connection_mock): + self.module._get_resource_parameters(module_params, connection_mock) + + @pytest.mark.parametrize("module_params,data", [({"system_query_options": None}, None), ({"system_query_options": {"fileter": None}}, None), + ({"system_query_options": {"filter": "abc"}}, "$filter")]) + def test_get_query_parameters(self, module_params, data): + res = self.module._get_query_parameters(module_params) + if data is not None: + assert data in res + else: + assert res is None + + @pytest.mark.parametrize("module_params", params) + def test_get_device_identifier_map(self, module_params, connection_mock, mocker): + get_device_id_from_service_tags_mock = mocker.patch('ansible.modules.remote_management.dellemc.ome.ome_device_info._get_device_id_from_service_tags') + get_device_id_from_service_tags_mock.return_value = None + res = self.module._get_device_identifier_map(module_params, connection_mock) + assert isinstance(res, dict) + + def test_check_duplicate_device_id(self): + self.module._check_duplicate_device_id([1234], {1234: "MX1234"}) + assert self.module.device_fact_error_report["MX1234"] == "Duplicate report of device_id: 1234" + + @pytest.mark.parametrize("val,expected_res", [(123, True), ("abc", False)]) + def test_is_int(self, val, expected_res): + actual_res = self.module.is_int(val) + assert actual_res == expected_res + + def test_get_device_id_from_service_tags(self, connection_mock, response_mock): + connection_mock.__enter__.return_value = connection_mock + connection_mock.invoke_request.return_value = response_mock + response_mock.json_data = {"value": [{"DeviceServiceTag": "MX1234", "Id": 1234}]} + response_mock.status_code = 200 + response_mock.success = True + self.module._get_device_id_from_service_tags(["MX1234", "INVALID"], connection_mock) + + def test_get_device_id_from_service_tags_error_case(self, connection_mock, response_mock): + connection_mock.__enter__.return_value = connection_mock + connection_mock.invoke_request.side_effect = HTTPError('http://testhost.com', + 400, '', {}, None) + response_mock.json_data = {"value": [{"DeviceServiceTag": "MX1234", "Id": 1234}]} + response_mock.status_code = 200 + response_mock.success = True + with pytest.raises(HTTPError) as ex: + self.module._get_device_id_from_service_tags(["INVALID"], connection_mock) + + def _run_module(self, module_args): + set_module_args(module_args) + with pytest.raises(AnsibleExitJson) as ex: + self.module.main() + return ex.value.args[0] + + def _run_module_with_fail_json(self, module_args): + set_module_args(module_args) + with pytest.raises(AnsibleFailJson) as exc: + self.module.main() + result = exc.value.args[0] + return result