Repository: ansible-collections/community.general

Issue:
Redfish: support Virtual Media configuration
<!--- Verify first that your feature was not already discussed on GitHub -->
<!--- Complete *all* sections as described, this form is processed automatically -->

##### SUMMARY
<!--- Describe the new feature/improvement briefly below -->
Add support for Virtual Media configuration

##### ISSUE TYPE
- Feature Idea

##### COMPONENT NAME
<!--- Write the short name of the module, plugin, task or feature below, use your best guess if unsure -->
redfish_command

##### ADDITIONAL INFORMATION
<!--- Describe how the feature would be used, why it is needed and what it would solve -->

The feature will be used for configuration of Virtual Media:

- insert an ISO image as a CD, DVD or USB device
- eject the virtual media

See additional discussion in the original issue here:
https://github.com/ansible/ansible/issues/58941

<!--- HINT: You can also paste gist.github.com links for larger files -->



Discussion:
Files identified in the description:
* [`plugins/modules/remote_management/redfish/redfish_command.py`](https://github.com/ansible-collections/community.general/blob/master/plugins/modules/remote_management/redfish/redfish_command.py)

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @jose-delarosa @mraineri @tomasg2012
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->

Resolving patch:
diff --git a/plugins/module_utils/redfish_utils.py b/plugins/module_utils/redfish_utils.py
--- a/plugins/module_utils/redfish_utils.py
+++ b/plugins/module_utils/redfish_utils.py
@@ -9,6 +9,7 @@
 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 @@ def _find_managers_resource(self):
                                       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 @@ def manage_system_power(self, command):
                     '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 @@ def get_software_inventory(self):
         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 @@ def get_multi_virtualmedia(self):
                                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
--- a/plugins/modules/remote_management/redfish/redfish_command.py
+++ b/plugins/modules/remote_management/redfish/redfish_command.py
@@ -135,6 +135,55 @@
         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 @@
       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 @@
                  "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


Test patch: