diff --git a/changelogs/fragments/5059-wdc_redfish_command-indicator-leds.yml b/changelogs/fragments/5059-wdc_redfish_command-indicator-leds.yml new file mode 100644 index 0000000000..0a00b44e73 --- /dev/null +++ b/changelogs/fragments/5059-wdc_redfish_command-indicator-leds.yml @@ -0,0 +1,2 @@ +minor_changes: + - wdc_redfish_command - add ``IndicatorLedOn`` and ``IndicatorLedOff`` commands for ``Chassis`` category (https://github.com/ansible-collections/community.general/pull/5059). diff --git a/plugins/module_utils/wdc_redfish_utils.py b/plugins/module_utils/wdc_redfish_utils.py index 44289e86d7..0ae22de64a 100644 --- a/plugins/module_utils/wdc_redfish_utils.py +++ b/plugins/module_utils/wdc_redfish_utils.py @@ -405,3 +405,50 @@ class WdcRedfishUtils(RedfishUtils): return iom_b_firmware_version else: return None + + @staticmethod + def _get_led_locate_uri(data): + """Get the LED locate URI given a resource body.""" + if "Actions" not in data: + return None + if "Oem" not in data["Actions"]: + return None + if "WDC" not in data["Actions"]["Oem"]: + return None + if "#Chassis.Locate" not in data["Actions"]["Oem"]["WDC"]: + return None + if "target" not in data["Actions"]["Oem"]["WDC"]["#Chassis.Locate"]: + return None + return data["Actions"]["Oem"]["WDC"]["#Chassis.Locate"]["target"] + + def manage_indicator_led(self, command, resource_uri): + key = 'IndicatorLED' + + payloads = {'IndicatorLedOn': 'On', 'IndicatorLedOff': 'Off'} + current_led_status_map = {'IndicatorLedOn': 'Blinking', 'IndicatorLedOff': 'Off'} + + result = {} + response = self.get_request(self.root_uri + resource_uri) + if response['ret'] is False: + return response + result['ret'] = True + data = response['data'] + if key not in data: + return {'ret': False, 'msg': "Key %s not found" % key} + current_led_status = data[key] + if current_led_status == current_led_status_map[command]: + return {'ret': True, 'changed': False} + + led_locate_uri = self._get_led_locate_uri(data) + if led_locate_uri is None: + return {'ret': False, 'msg': 'LED locate URI not found.'} + + if command in payloads.keys(): + payload = {'LocateState': payloads[command]} + response = self.post_request(self.root_uri + led_locate_uri, payload) + if response['ret'] is False: + return response + else: + return {'ret': False, 'msg': 'Invalid command'} + + return result diff --git a/plugins/modules/remote_management/redfish/wdc_redfish_command.py b/plugins/modules/remote_management/redfish/wdc_redfish_command.py index 809e3984bd..29d6f304ef 100644 --- a/plugins/modules/remote_management/redfish/wdc_redfish_command.py +++ b/plugins/modules/remote_management/redfish/wdc_redfish_command.py @@ -55,6 +55,12 @@ options: - Timeout in seconds for URL requests to OOB controller. default: 10 type: int + resource_id: + required: false + description: + - ID of the component to modify, such as C(Enclosure), C(IOModuleAFRU), C(PowerSupplyBFRU), C(FanExternalFRU3), or C(FanInternalFRU). + type: str + version_added: 5.4.0 update_image_uri: required: false description: @@ -76,8 +82,6 @@ options: description: - The password for retrieving the update image. type: str -requirements: - - dnspython (2.1.0 for Python 3, 1.16.0 for Python 2) notes: - In the inventory, you can specify baseuri or ioms. See the EXAMPLES section. - ioms is a list of FQDNs for the enclosure's IOMs. @@ -125,6 +129,47 @@ EXAMPLES = ''' update_creds: username: operator password: supersecretpwd + +- name: Turn on enclosure indicator LED + community.general.wdc_redfish_command: + category: Chassis + resource_id: Enclosure + command: IndicatorLedOn + username: "{{ username }}" + password: "{{ password }}" + +- name: Turn off IOM A indicator LED + community.general.wdc_redfish_command: + category: Chassis + resource_id: IOModuleAFRU + command: IndicatorLedOff + username: "{{ username }}" + password: "{{ password }}" + +- name: Turn on Power Supply B indicator LED + community.general.wdc_redfish_command: + category: Chassis + resource_id: PowerSupplyBFRU + command: IndicatorLedOn + username: "{{ username }}" + password: "{{ password }}" + +- name: Turn on External Fan 3 indicator LED + community.general.wdc_redfish_command: + category: Chassis + resource_id: FanExternalFRU3 + command: IndicatorLedOn + username: "{{ username }}" + password: "{{ password }}" + +- name: Turn on Internal Fan indicator LED + community.general.wdc_redfish_command: + category: Chassis + resource_id: FanInternalFRU + command: IndicatorLedOn + username: "{{ username }}" + password: "{{ password }}" + ''' RETURN = ''' @@ -143,6 +188,10 @@ CATEGORY_COMMANDS_ALL = { "Update": [ "FWActivate", "UpdateAndActivate" + ], + "Chassis": [ + "IndicatorLedOn", + "IndicatorLedOff" ] } @@ -164,6 +213,7 @@ def main(): password=dict(no_log=True) ) ), + resource_id=dict(), update_image_uri=dict(), timeout=dict(type='int', default=10) ), @@ -191,6 +241,9 @@ def main(): # timeout timeout = module.params['timeout'] + # Resource to modify + resource_id = module.params['resource_id'] + # Check that Category is valid if category not in CATEGORY_COMMANDS_ALL: module.fail_json(msg=to_native("Invalid Category '%s'. Valid Categories = %s" % (category, sorted(CATEGORY_COMMANDS_ALL.keys())))) @@ -209,7 +262,7 @@ def main(): "https://" + iom for iom in module.params['ioms'] ] rf_utils = WdcRedfishUtils(creds, root_uris, timeout, module, - resource_id=None, data_modification=True) + resource_id=resource_id, data_modification=True) # Organize by Categories / Commands @@ -236,17 +289,33 @@ def main(): update_opts["update_image_uri"] = module.params['update_image_uri'] result = rf_utils.update_and_activate(update_opts) + elif category == "Chassis": + result = rf_utils._find_chassis_resource() if result['ret'] is False: module.fail_json(msg=to_native(result['msg'])) + + led_commands = ["IndicatorLedOn", "IndicatorLedOff"] + + # Check if more than one led_command is present + num_led_commands = sum([command in led_commands for command in command_list]) + if num_led_commands > 1: + result = {'ret': False, 'msg': "Only one IndicatorLed command should be sent at a time."} else: - del result['ret'] - changed = result.get('changed', True) - session = result.get('session', dict()) - module.exit_json(changed=changed, - session=session, - msg='Action was successful' if not module.check_mode else result.get( - 'msg', "No action performed in check mode." - )) + for command in command_list: + if command.startswith("IndicatorLed"): + result = rf_utils.manage_chassis_indicator_led(command) + + if result['ret'] is False: + module.fail_json(msg=to_native(result['msg'])) + else: + del result['ret'] + changed = result.get('changed', True) + session = result.get('session', dict()) + module.exit_json(changed=changed, + session=session, + msg='Action was successful' if not module.check_mode else result.get( + 'msg', "No action performed in check mode." + )) if __name__ == '__main__': diff --git a/tests/unit/plugins/modules/remote_management/wdc/test_wdc_redfish_command.py b/tests/unit/plugins/modules/remote_management/wdc/test_wdc_redfish_command.py index 53e59ebd74..1e66acc082 100644 --- a/tests/unit/plugins/modules/remote_management/wdc/test_wdc_redfish_command.py +++ b/tests/unit/plugins/modules/remote_management/wdc/test_wdc_redfish_command.py @@ -49,6 +49,37 @@ MOCK_SUCCESSFUL_RESPONSE_WITH_UPDATE_SERVICE_RESOURCE = { "data": { "UpdateService": { "@odata.id": "/UpdateService" + }, + "Chassis": { + "@odata.id": "/Chassis" + } + } +} + +MOCK_SUCCESSFUL_RESPONSE_CHASSIS = { + "ret": True, + "data": { + "Members": [ + { + "@odata.id": "/redfish/v1/Chassis/Enclosure" + } + ] + } +} + +MOCK_SUCCESSFUL_RESPONSE_CHASSIS_ENCLOSURE = { + "ret": True, + "data": { + "Id": "Enclosure", + "IndicatorLED": "Off", + "Actions": { + "Oem": { + "WDC": { + "#Chassis.Locate": { + "target": "/Chassis.Locate" + } + } + } } } } @@ -205,15 +236,31 @@ def mock_get_request_enclosure_multi_tenant(*args, **kwargs): raise RuntimeError("Illegal call to get_request in test: " + args[1]) +def mock_get_request_led_indicator(*args, **kwargs): + """Mock for get_request for LED indicator tests.""" + if args[1].endswith("/redfish/v1") or args[1].endswith("/redfish/v1/"): + return MOCK_SUCCESSFUL_RESPONSE_WITH_UPDATE_SERVICE_RESOURCE + elif args[1].endswith("/Chassis"): + return MOCK_SUCCESSFUL_RESPONSE_CHASSIS + elif args[1].endswith("Chassis/Enclosure"): + return MOCK_SUCCESSFUL_RESPONSE_CHASSIS_ENCLOSURE + else: + raise RuntimeError("Illegal call to get_request in test: " + args[1]) + + def mock_post_request(*args, **kwargs): """Mock post_request with successful response.""" - if args[1].endswith("/UpdateService.FWActivate"): - return { - "ret": True, - "data": ACTION_WAS_SUCCESSFUL_MESSAGE - } - else: - raise RuntimeError("Illegal POST call to: " + args[1]) + valid_endpoints = [ + "/UpdateService.FWActivate", + "/Chassis.Locate" + ] + for endpoint in valid_endpoints: + if args[1].endswith(endpoint): + return { + "ret": True, + "data": ACTION_WAS_SUCCESSFUL_MESSAGE + } + raise RuntimeError("Illegal POST call to: " + args[1]) def mock_get_firmware_inventory_version_1_2_3(*args, **kwargs): @@ -277,6 +324,68 @@ class TestWdcRedfishCommand(unittest.TestCase): }) module.main() + def test_module_enclosure_led_indicator_on(self): + """Test turning on a valid LED indicator (in this case we use the Enclosure resource).""" + module_args = { + 'category': 'Chassis', + 'command': 'IndicatorLedOn', + 'username': 'USERID', + 'password': 'PASSW0RD=21', + "resource_id": "Enclosure", + "baseuri": "example.com" + } + set_module_args(module_args) + + with patch.multiple("ansible_collections.community.general.plugins.module_utils.wdc_redfish_utils.WdcRedfishUtils", + get_request=mock_get_request_led_indicator, + post_request=mock_post_request): + with self.assertRaises(AnsibleExitJson) as ansible_exit_json: + module.main() + self.assertEqual(ACTION_WAS_SUCCESSFUL_MESSAGE, + get_exception_message(ansible_exit_json)) + self.assertTrue(is_changed(ansible_exit_json)) + + def test_module_invalid_resource_led_indicator_on(self): + """Test turning LED on for an invalid resource id.""" + module_args = { + 'category': 'Chassis', + 'command': 'IndicatorLedOn', + 'username': 'USERID', + 'password': 'PASSW0RD=21', + "resource_id": "Disk99", + "baseuri": "example.com" + } + set_module_args(module_args) + + with patch.multiple("ansible_collections.community.general.plugins.module_utils.wdc_redfish_utils.WdcRedfishUtils", + get_request=mock_get_request_led_indicator, + post_request=mock_post_request): + with self.assertRaises(AnsibleFailJson) as ansible_fail_json: + module.main() + expected_error_message = "Chassis resource Disk99 not found" + self.assertEqual(expected_error_message, + get_exception_message(ansible_fail_json)) + + def test_module_enclosure_led_off_already_off(self): + """Test turning LED indicator off when it's already off. Confirm changed is False and no POST occurs.""" + module_args = { + 'category': 'Chassis', + 'command': 'IndicatorLedOff', + 'username': 'USERID', + 'password': 'PASSW0RD=21', + "resource_id": "Enclosure", + "baseuri": "example.com" + } + set_module_args(module_args) + + with patch.multiple("ansible_collections.community.general.plugins.module_utils.wdc_redfish_utils.WdcRedfishUtils", + get_request=mock_get_request_led_indicator): + with self.assertRaises(AnsibleExitJson) as ansible_exit_json: + module.main() + self.assertEqual(ACTION_WAS_SUCCESSFUL_MESSAGE, + get_exception_message(ansible_exit_json)) + self.assertFalse(is_changed(ansible_exit_json)) + def test_module_fw_activate_first_iom_unavailable(self): """Test that if the first IOM is not available, the 2nd one is used.""" ioms = [