diff --git a/changelogs/fragments/494-add-redfish-virtual-media-commands.yml b/changelogs/fragments/494-add-redfish-virtual-media-commands.yml new file mode 100644 index 0000000000..68be43f327 --- /dev/null +++ b/changelogs/fragments/494-add-redfish-virtual-media-commands.yml @@ -0,0 +1,2 @@ +minor_changes: + - redfish_command - Support for virtual media insert and eject commands (https://github.com/ansible-collections/community.general/issues/493) diff --git a/plugins/module_utils/redfish_utils.py b/plugins/module_utils/redfish_utils.py index 8fc6b42e4d..151899f3e4 100644 --- a/plugins/module_utils/redfish_utils.py +++ b/plugins/module_utils/redfish_utils.py @@ -9,6 +9,7 @@ from ansible.module_utils.urls import open_url from ansible.module_utils._text import to_text from ansible.module_utils.six.moves import http_client from ansible.module_utils.six.moves.urllib.error import URLError, HTTPError +from ansible.module_utils.six.moves.urllib.parse import urlparse GET_HEADERS = {'accept': 'application/json', 'OData-Version': '4.0'} POST_HEADERS = {'content-type': 'application/json', 'accept': 'application/json', @@ -328,6 +329,37 @@ class RedfishUtils(object): version='2.14') return {'ret': True} + def _get_all_action_info_values(self, action): + """Retrieve all parameter values for an Action from ActionInfo. + Fall back to AllowableValue annotations if no ActionInfo found. + Return the result in an ActionInfo-like dictionary, keyed + by the name of the parameter. """ + ai = {} + if '@Redfish.ActionInfo' in action: + ai_uri = action['@Redfish.ActionInfo'] + response = self.get_request(self.root_uri + ai_uri) + if response['ret'] is True: + data = response['data'] + if 'Parameters' in data: + params = data['Parameters'] + ai = dict((p['Name'], p) + for p in params if 'Name' in p) + if not ai: + ai = dict((k[:-24], + {'AllowableValues': v}) for k, v in action.items() + if k.endswith('@Redfish.AllowableValues')) + return ai + + def _get_allowable_values(self, action, name, default_values=None): + if default_values is None: + default_values = [] + ai = self._get_all_action_info_values(action) + allowable_values = ai.get(name, {}).get('AllowableValues') + # fallback to default values + if allowable_values is None: + allowable_values = default_values + return allowable_values + def get_logs(self): log_svcs_uri_list = [] list_of_logs = [] @@ -779,23 +811,9 @@ class RedfishUtils(object): 'msg': 'target URI missing from Action #ComputerSystem.Reset'} action_uri = reset_action['target'] - # get AllowableValues from ActionInfo - allowable_values = None - if '@Redfish.ActionInfo' in reset_action: - action_info_uri = reset_action.get('@Redfish.ActionInfo') - response = self.get_request(self.root_uri + action_info_uri) - if response['ret'] is True: - data = response['data'] - if 'Parameters' in data: - params = data['Parameters'] - for param in params: - if param.get('Name') == 'ResetType': - allowable_values = param.get('AllowableValues') - break - - # fallback to @Redfish.AllowableValues annotation - if allowable_values is None: - allowable_values = reset_action.get('ResetType@Redfish.AllowableValues', []) + # get AllowableValues + ai = self._get_all_action_info_values(reset_action) + allowable_values = ai.get('ResetType', {}).get('AllowableValues', []) # map ResetType to an allowable value if needed if reset_type not in allowable_values: @@ -1255,32 +1273,6 @@ class RedfishUtils(object): else: return self._software_inventory(self.software_uri) - def _get_allowable_values(self, action, name, default_values=None): - if default_values is None: - default_values = [] - allowable_values = None - # get Allowable values from ActionInfo - if '@Redfish.ActionInfo' in action: - action_info_uri = action.get('@Redfish.ActionInfo') - response = self.get_request(self.root_uri + action_info_uri) - if response['ret'] is True: - data = response['data'] - if 'Parameters' in data: - params = data['Parameters'] - for param in params: - if param.get('Name') == name: - allowable_values = param.get('AllowableValues') - break - # fallback to @Redfish.AllowableValues annotation - if allowable_values is None: - prop = '%s@Redfish.AllowableValues' % name - if prop in action: - allowable_values = action[prop] - # fallback to default values - if allowable_values is None: - allowable_values = default_values - return allowable_values - def simple_update(self, update_opts): image_uri = update_opts.get('update_image_uri') protocol = update_opts.get('update_protocol') @@ -2069,6 +2061,248 @@ class RedfishUtils(object): virtualmedia['entries'])) return dict(ret=ret, entries=entries) + @staticmethod + def _find_empty_virt_media_slot(resources, media_types, + media_match_strict=True): + for uri, data in resources.items(): + # check MediaTypes + if 'MediaTypes' in data and media_types: + if not set(media_types).intersection(set(data['MediaTypes'])): + continue + else: + if media_match_strict: + continue + # if ejected, 'Inserted' should be False and 'ImageName' cleared + if (not data.get('Inserted', False) and + not data.get('ImageName')): + return uri, data + return None, None + + @staticmethod + def _virt_media_image_inserted(resources, image_url): + for uri, data in resources.items(): + if data.get('Image'): + if urlparse(image_url) == urlparse(data.get('Image')): + if data.get('Inserted', False) and data.get('ImageName'): + return True + return False + + @staticmethod + def _find_virt_media_to_eject(resources, image_url): + matched_uri, matched_data = None, None + for uri, data in resources.items(): + if data.get('Image'): + if urlparse(image_url) == urlparse(data.get('Image')): + matched_uri, matched_data = uri, data + if data.get('Inserted', True) and data.get('ImageName', 'x'): + return uri, data, True + return matched_uri, matched_data, False + + def _read_virt_media_resources(self, uri_list): + resources = {} + headers = {} + for uri in uri_list: + response = self.get_request(self.root_uri + uri) + if response['ret'] is False: + continue + resources[uri] = response['data'] + headers[uri] = response['headers'] + return resources, headers + + @staticmethod + def _insert_virt_media_payload(options, param_map, data, ai): + payload = { + 'Image': options.get('image_url') + } + for param, option in param_map.items(): + if options.get(option) is not None and param in data: + allowable = ai.get(param, {}).get('AllowableValues', []) + if allowable and options.get(option) not in allowable: + return {'ret': False, + 'msg': "Value '%s' specified for option '%s' not " + "in list of AllowableValues %s" % ( + options.get(option), option, + allowable)} + payload[param] = options.get(option) + return payload + + def virtual_media_insert_via_patch(self, options, param_map, uri, data): + # get AllowableValues + ai = dict((k[:-24], + {'AllowableValues': v}) for k, v in data.items() + if k.endswith('@Redfish.AllowableValues')) + # construct payload + payload = self._insert_virt_media_payload(options, param_map, data, ai) + if 'Inserted' not in payload: + payload['Inserted'] = True + # PATCH the resource + response = self.patch_request(self.root_uri + uri, payload) + if response['ret'] is False: + return response + return {'ret': True, 'changed': True, 'msg': "VirtualMedia inserted"} + + def virtual_media_insert(self, options): + param_map = { + 'Inserted': 'inserted', + 'WriteProtected': 'write_protected', + 'UserName': 'username', + 'Password': 'password', + 'TransferProtocolType': 'transfer_protocol_type', + 'TransferMethod': 'transfer_method' + } + image_url = options.get('image_url') + if not image_url: + return {'ret': False, + 'msg': "image_url option required for VirtualMediaInsert"} + media_types = options.get('media_types') + + # locate and read the VirtualMedia resources + response = self.get_request(self.root_uri + self.manager_uri) + if response['ret'] is False: + return response + data = response['data'] + if 'VirtualMedia' not in data: + return {'ret': False, 'msg': "VirtualMedia resource not found"} + virt_media_uri = data["VirtualMedia"]["@odata.id"] + response = self.get_request(self.root_uri + virt_media_uri) + if response['ret'] is False: + return response + data = response['data'] + virt_media_list = [] + for member in data[u'Members']: + virt_media_list.append(member[u'@odata.id']) + resources, headers = self._read_virt_media_resources(virt_media_list) + + # see if image already inserted; if so, nothing to do + if self._virt_media_image_inserted(resources, image_url): + return {'ret': True, 'changed': False, + 'msg': "VirtualMedia '%s' already inserted" % image_url} + + # find an empty slot to insert the media + # try first with strict media_type matching + uri, data = self._find_empty_virt_media_slot( + resources, media_types, media_match_strict=True) + if not uri: + # if not found, try without strict media_type matching + uri, data = self._find_empty_virt_media_slot( + resources, media_types, media_match_strict=False) + if not uri: + return {'ret': False, + 'msg': "Unable to find an available VirtualMedia resource " + "%s" % ('supporting ' + str(media_types) + if media_types else '')} + + # confirm InsertMedia action found + if ('Actions' not in data or + '#VirtualMedia.InsertMedia' not in data['Actions']): + # try to insert via PATCH if no InsertMedia action found + h = headers[uri] + if 'allow' in h: + methods = [m.strip() for m in h.get('allow').split(',')] + if 'PATCH' not in methods: + # if Allow header present and PATCH missing, return error + return {'ret': False, + 'msg': "%s action not found and PATCH not allowed" + % '#VirtualMedia.InsertMedia'} + return self.virtual_media_insert_via_patch(options, param_map, + uri, data) + + # get the action property + action = data['Actions']['#VirtualMedia.InsertMedia'] + if 'target' not in action: + return {'ret': False, + 'msg': "target URI missing from Action " + "#VirtualMedia.InsertMedia"} + action_uri = action['target'] + # get ActionInfo or AllowableValues + ai = self._get_all_action_info_values(action) + # construct payload + payload = self._insert_virt_media_payload(options, param_map, data, ai) + # POST to action + response = self.post_request(self.root_uri + action_uri, payload) + if response['ret'] is False: + return response + return {'ret': True, 'changed': True, 'msg': "VirtualMedia inserted"} + + def virtual_media_eject_via_patch(self, uri): + # construct payload + payload = { + 'Inserted': False, + 'Image': None + } + # PATCH resource + response = self.patch_request(self.root_uri + uri, payload) + if response['ret'] is False: + return response + return {'ret': True, 'changed': True, + 'msg': "VirtualMedia ejected"} + + def virtual_media_eject(self, options): + image_url = options.get('image_url') + if not image_url: + return {'ret': False, + 'msg': "image_url option required for VirtualMediaEject"} + + # locate and read the VirtualMedia resources + response = self.get_request(self.root_uri + self.manager_uri) + if response['ret'] is False: + return response + data = response['data'] + if 'VirtualMedia' not in data: + return {'ret': False, 'msg': "VirtualMedia resource not found"} + virt_media_uri = data["VirtualMedia"]["@odata.id"] + response = self.get_request(self.root_uri + virt_media_uri) + if response['ret'] is False: + return response + data = response['data'] + virt_media_list = [] + for member in data[u'Members']: + virt_media_list.append(member[u'@odata.id']) + resources, headers = self._read_virt_media_resources(virt_media_list) + + # find the VirtualMedia resource to eject + uri, data, eject = self._find_virt_media_to_eject(resources, image_url) + if uri and eject: + if ('Actions' not in data or + '#VirtualMedia.EjectMedia' not in data['Actions']): + # try to eject via PATCH if no EjectMedia action found + h = headers[uri] + if 'allow' in h: + methods = [m.strip() for m in h.get('allow').split(',')] + if 'PATCH' not in methods: + # if Allow header present and PATCH missing, return error + return {'ret': False, + 'msg': "%s action not found and PATCH not allowed" + % '#VirtualMedia.EjectMedia'} + return self.virtual_media_eject_via_patch(uri) + else: + # POST to the EjectMedia Action + action = data['Actions']['#VirtualMedia.EjectMedia'] + if 'target' not in action: + return {'ret': False, + 'msg': "target URI property missing from Action " + "#VirtualMedia.EjectMedia"} + action_uri = action['target'] + # empty payload for Eject action + payload = {} + # POST to action + response = self.post_request(self.root_uri + action_uri, + payload) + if response['ret'] is False: + return response + return {'ret': True, 'changed': True, + 'msg': "VirtualMedia ejected"} + elif uri and not eject: + # already ejected: return success but changed=False + return {'ret': True, 'changed': False, + 'msg': "VirtualMedia image '%s' already ejected" % + image_url} + else: + # return failure (no resources matching image_url found) + return {'ret': False, 'changed': False, + 'msg': "No VirtualMedia resource found with image '%s' " + "inserted" % image_url} + def get_psu_inventory(self): result = {} psu_list = [] diff --git a/plugins/modules/remote_management/redfish/redfish_command.py b/plugins/modules/remote_management/redfish/redfish_command.py index e5d1d2f79d..3860d09a08 100644 --- a/plugins/modules/remote_management/redfish/redfish_command.py +++ b/plugins/modules/remote_management/redfish/redfish_command.py @@ -135,6 +135,55 @@ options: description: - The password for retrieving the update image type: str + virtual_media: + required: false + description: + - The options for VirtualMedia commands + type: dict + suboptions: + media_types: + required: false + description: + - The list of media types appropriate for the image + type: list + elements: str + image_url: + required: false + description: + - The URL od the image the insert or eject + type: str + inserted: + required: false + description: + - Indicates if the image is treated as inserted on command completion + type: bool + default: True + write_protected: + required: false + description: + - Indicates if the media is treated as write-protected + type: bool + default: True + username: + required: false + description: + - The username for accessing the image URL + type: str + password: + required: false + description: + - The password for accessing the image URL + type: str + transfer_protocol_type: + required: false + description: + - The network protocol to use with the image + type: str + transfer_method: + required: false + description: + - The transfer method to use with the image + type: str author: "Jose Delarosa (@jose-delarosa)" ''' @@ -342,6 +391,31 @@ EXAMPLES = ''' update_creds: username: operator password: supersecretpwd + + - name: Insert Virtual Media + redfish_command: + category: Manager + command: VirtualMediaInsert + baseuri: "{{ baseuri }}" + username: "{{ username }}" + password: "{{ password }}" + virtual_media: + image_url: 'http://example.com/images/SomeLinux-current.iso' + media_types: + - CD + - DVD + resource_id: BMC + + - name: Eject Virtual Media + redfish_command: + category: Manager + command: VirtualMediaEject + baseuri: "{{ baseuri }}" + username: "{{ username }}" + password: "{{ password }}" + virtual_media: + image_url: 'http://example.com/images/SomeLinux-current.iso' + resource_id: BMC ''' RETURN = ''' @@ -366,7 +440,8 @@ CATEGORY_COMMANDS_ALL = { "UpdateUserRole", "UpdateUserPassword", "UpdateUserName", "UpdateAccountServiceProperties"], "Sessions": ["ClearSessions"], - "Manager": ["GracefulRestart", "ClearLogs"], + "Manager": ["GracefulRestart", "ClearLogs", "VirtualMediaInsert", + "VirtualMediaEject"], "Update": ["SimpleUpdate"] } @@ -400,6 +475,19 @@ def main(): username=dict(), password=dict() ) + ), + virtual_media=dict( + type='dict', + options=dict( + media_types=dict(type='list', elements='str', default=[]), + image_url=dict(), + inserted=dict(type='bool', default=True), + write_protected=dict(type='bool', default=True), + username=dict(), + password=dict(no_log=True), + transfer_protocol_type=dict(), + transfer_method=dict(), + ) ) ), supports_check_mode=False @@ -434,6 +522,9 @@ def main(): 'update_creds': module.params['update_creds'] } + # VirtualMedia options + virtual_media = module.params['virtual_media'] + # Build root URI root_uri = "https://" + module.params['baseuri'] rf_utils = RedfishUtils(creds, root_uri, timeout, module, @@ -512,18 +603,20 @@ def main(): result = rf_utils.clear_sessions() elif category == "Manager": - MANAGER_COMMANDS = { - "GracefulRestart": rf_utils.restart_manager_gracefully, - "ClearLogs": rf_utils.clear_logs - } - # execute only if we find a Manager service resource result = rf_utils._find_managers_resource() if result['ret'] is False: module.fail_json(msg=to_native(result['msg'])) for command in command_list: - result = MANAGER_COMMANDS[command]() + if command == 'GracefulRestart': + result = rf_utils.restart_manager_gracefully() + elif command == 'ClearLogs': + result = rf_utils.clear_logs() + elif command == 'VirtualMediaInsert': + result = rf_utils.virtual_media_insert(virtual_media) + elif command == 'VirtualMediaEject': + result = rf_utils.virtual_media_eject(virtual_media) elif category == "Update": # execute only if we find UpdateService resources