Repository: ansible-collections/community.general

Issue:
Redfish Support multi-part HTTP Push Update
### Summary

At the moment we have GPU devices that only support HTTP push Redfish update capability. Would it be possible to open a discussion around implementing that functionality?

Per https://github.com/ansible-collections/community.general/issues/3910 I see that it isn't supported and doesn't appear to be on a roadmap that I could find.

@mraineri - thank you in advance :) 

### Issue Type

Feature Idea

### Component Name

redfish_command

### Additional Information

Potentially implement it as:

```
- name: Update with http push
  community.general.redfish_command:
    category: Update
    command: PushUpdate
    baseuri: "{{ baseuri }}"
    username: "{{ username }}"
    password: "{{ password }}"
    update_image_file: file_to_push_to_target
    update_targets:
      - /redfish/v1/UpdateService/upload
```

### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


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

If these files are incorrect, 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/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @TSKushal @bhavya06 @jyundt @mraineri @rajeevkallur @renxulei @tomasg2012 @xmadsen
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->
@sseekamp, before starting down this path, could you confirm that "/redfish/v1/UpdateService" contains the property "MultipartHttpPushUri" for your system?
```
  "Id": "UpdateService",
  "MaxImageSizeBytes": 205824000,
  "MultipartHttpPushUri": "/redfish/v1/UpdateService/upload",
  "Name": "Update Service",
```
Perfect, thanks!
Revisiting - we have another vendor that is multipart upload and not simple update capable if that helps with effort to value calculations :) 
Thanks for the ping; I lost this from my radar.

I don't think it should be hard to support; most of the building blocks are in place, and the multipart encoding can be leveraged from the requests toolkit module.

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
@@ -7,9 +7,14 @@
 __metaclass__ = type
 
 import json
+import os
+import random
+import string
 from ansible.module_utils.urls import open_url
 from ansible.module_utils.common.text.converters import to_native
 from ansible.module_utils.common.text.converters import to_text
+from ansible.module_utils.common.text.converters import to_bytes
+from ansible.module_utils.six import text_type
 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
@@ -153,7 +158,7 @@ def get_request(self, uri):
                     'msg': "Failed GET request to '%s': '%s'" % (uri, to_text(e))}
         return {'ret': True, 'data': data, 'headers': headers, 'resp': resp}
 
-    def post_request(self, uri, pyld):
+    def post_request(self, uri, pyld, multipart=False):
         req_headers = dict(POST_HEADERS)
         username, password, basic_auth = self._auth_params(req_headers)
         try:
@@ -162,7 +167,14 @@ def post_request(self, uri, pyld):
             # header since this can cause conflicts with some services
             if self.sessions_uri is not None and uri == (self.root_uri + self.sessions_uri):
                 basic_auth = False
-            resp = open_url(uri, data=json.dumps(pyld),
+            if multipart:
+                # Multipart requests require special handling to encode the request body
+                multipart_encoder = self._prepare_multipart(pyld)
+                data = multipart_encoder[0]
+                req_headers['content-type'] = multipart_encoder[1]
+            else:
+                data = json.dumps(pyld)
+            resp = open_url(uri, data=data,
                             headers=req_headers, method="POST",
                             url_username=username, url_password=password,
                             force_basic_auth=basic_auth, validate_certs=False,
@@ -298,6 +310,59 @@ def delete_request(self, uri, pyld=None):
                     'msg': "Failed DELETE request to '%s': '%s'" % (uri, to_text(e))}
         return {'ret': True, 'resp': resp}
 
+    @staticmethod
+    def _prepare_multipart(fields):
+        """Prepares a multipart body based on a set of fields provided.
+
+        Ideally it would have been good to use the existing 'prepare_multipart'
+        found in ansible.module_utils.urls, but it takes files and encodes them
+        as Base64 strings, which is not expected by Redfish services.  It also
+        adds escaping of certain bytes in the payload, such as inserting '\r'
+        any time it finds a standlone '\n', which corrupts the image payload
+        send to the service.  This implementation is simplified to Redfish's
+        usage and doesn't necessarily represent an exhaustive method of
+        building multipart requests.
+        """
+
+        def write_buffer(body, line):
+            # Adds to the multipart body based on the provided data type
+            # At this time there is only support for strings, dictionaries, and bytes (default)
+            if isinstance(line, text_type):
+                body.append(to_bytes(line, encoding='utf-8'))
+            elif isinstance(line, dict):
+                body.append(to_bytes(json.dumps(line), encoding='utf-8'))
+            else:
+                body.append(line)
+            return
+
+        # Generate a random boundary marker; may need to consider probing the
+        # payload for potential conflicts in the future
+        boundary = ''.join(random.choice(string.digits + string.ascii_letters) for i in range(30))
+        body = []
+        for form in fields:
+            # Fill in the form details
+            write_buffer(body, '--' + boundary)
+
+            # Insert the headers (Content-Disposition and Content-Type)
+            if 'filename' in fields[form]:
+                name = os.path.basename(fields[form]['filename']).replace('"', '\\"')
+                write_buffer(body, u'Content-Disposition: form-data; name="%s"; filename="%s"' % (to_text(form), to_text(name)))
+            else:
+                write_buffer(body, 'Content-Disposition: form-data; name="%s"' % form)
+            write_buffer(body, 'Content-Type: %s' % fields[form]['mime_type'])
+            write_buffer(body, '')
+
+            # Insert the payload; read from the file if not given by the caller
+            if 'content' not in fields[form]:
+                with open(to_bytes(fields[form]['filename'], errors='surrogate_or_strict'), 'rb') as f:
+                    fields[form]['content'] = f.read()
+            write_buffer(body, fields[form]['content'])
+
+        # Finalize the entire request
+        write_buffer(body, '--' + boundary + '--')
+        write_buffer(body, '')
+        return (b'\r\n'.join(body), 'multipart/form-data; boundary=' + boundary)
+
     @staticmethod
     def _get_extended_message(error):
         """
@@ -1572,6 +1637,61 @@ def simple_update(self, update_opts):
                 'msg': "SimpleUpdate requested",
                 'update_status': self._operation_results(response['resp'], response['data'])}
 
+    def multipath_http_push_update(self, update_opts):
+        """
+        Provides a software update via the URI specified by the
+        MultipartHttpPushUri property.  Callers should adjust the 'timeout'
+        variable in the base object to accommodate the size of the image and
+        speed of the transfer.  For example, a 200MB image will likely take
+        more than the default 10 second timeout.
+
+        :param update_opts: The parameters for the update operation
+        :return: dict containing the response of the update request
+        """
+        image_file = update_opts.get('update_image_file')
+        targets = update_opts.get('update_targets')
+        apply_time = update_opts.get('update_apply_time')
+
+        # Ensure the image file is provided
+        if not image_file:
+            return {'ret': False, 'msg':
+                    'Must specify update_image_file for the MultipartHTTPPushUpdate command'}
+        if not os.path.isfile(image_file):
+            return {'ret': False, 'msg':
+                    'Must specify a valid file for the MultipartHTTPPushUpdate command'}
+        try:
+            with open(image_file, 'rb') as f:
+                image_payload = f.read()
+        except Exception as e:
+            return {'ret': False, 'msg':
+                    'Could not read file %s' % image_file}
+
+        # Check that multipart HTTP push updates are supported
+        response = self.get_request(self.root_uri + self.update_uri)
+        if response['ret'] is False:
+            return response
+        data = response['data']
+        if 'MultipartHttpPushUri' not in data:
+            return {'ret': False, 'msg': 'Service does not support MultipartHttpPushUri'}
+        update_uri = data['MultipartHttpPushUri']
+
+        # Assemble the JSON payload portion of the request
+        payload = {"@Redfish.OperationApplyTime": "Immediate"}
+        if targets:
+            payload["Targets"] = targets
+        if apply_time:
+            payload["@Redfish.OperationApplyTime"] = apply_time
+        multipart_payload = {
+            'UpdateParameters': {'content': json.dumps(payload), 'mime_type': 'application/json'},
+            'UpdateFile': {'filename': image_file, 'content': image_payload, 'mime_type': 'application/octet-stream'}
+        }
+        response = self.post_request(self.root_uri + update_uri, multipart_payload, multipart=True)
+        if response['ret'] is False:
+            return response
+        return {'ret': True, 'changed': True,
+                'msg': "MultipartHTTPPushUpdate requested",
+                'update_status': self._operation_results(response['resp'], response['data'])}
+
     def get_update_status(self, update_handle):
         """
         Gets the status of an update operation.
diff --git a/plugins/modules/redfish_command.py b/plugins/modules/redfish_command.py
--- a/plugins/modules/redfish_command.py
+++ b/plugins/modules/redfish_command.py
@@ -137,6 +137,12 @@
       - URI of the image for the update.
     type: str
     version_added: '0.2.0'
+  update_image_file:
+    required: false
+    description:
+      - Filename, with optional path, of the image for the update.
+    type: path
+    version_added: '7.1.0'
   update_protocol:
     required: false
     description:
@@ -541,6 +547,30 @@
         username: operator
         password: supersecretpwd
 
+  - name: Multipart HTTP push update; timeout is 600 seconds to allow for a
+      large image transfer
+    community.general.redfish_command:
+      category: Update
+      command: MultipartHTTPPushUpdate
+      baseuri: "{{ baseuri }}"
+      username: "{{ username }}"
+      password: "{{ password }}"
+      timeout: 600
+      update_image_file: ~/images/myupdate.img
+
+  - name: Multipart HTTP push with additional options; timeout is 600 seconds
+      to allow for a large image transfer
+    community.general.redfish_command:
+      category: Update
+      command: MultipartHTTPPushUpdate
+      baseuri: "{{ baseuri }}"
+      username: "{{ username }}"
+      password: "{{ password }}"
+      timeout: 600
+      update_image_file: ~/images/myupdate.img
+      update_targets:
+        - /redfish/v1/UpdateService/FirmwareInventory/BMC
+
   - name: Perform requested operations to continue the update
     community.general.redfish_command:
       category: Update
@@ -697,7 +727,7 @@
     "Manager": ["GracefulRestart", "ClearLogs", "VirtualMediaInsert",
                 "VirtualMediaEject", "PowerOn", "PowerForceOff", "PowerForceRestart",
                 "PowerGracefulRestart", "PowerGracefulShutdown", "PowerReboot"],
-    "Update": ["SimpleUpdate", "PerformRequestedOperations"],
+    "Update": ["SimpleUpdate", "MultipartHTTPPushUpdate", "PerformRequestedOperations"],
 }
 
 
@@ -726,6 +756,7 @@ def main():
             boot_override_mode=dict(choices=['Legacy', 'UEFI']),
             resource_id=dict(),
             update_image_uri=dict(),
+            update_image_file=dict(type='path'),
             update_protocol=dict(),
             update_targets=dict(type='list', elements='str', default=[]),
             update_creds=dict(
@@ -791,6 +822,7 @@ def main():
     # update options
     update_opts = {
         'update_image_uri': module.params['update_image_uri'],
+        'update_image_file': module.params['update_image_file'],
         'update_protocol': module.params['update_protocol'],
         'update_targets': module.params['update_targets'],
         'update_creds': module.params['update_creds'],
@@ -940,6 +972,10 @@ def main():
                 result = rf_utils.simple_update(update_opts)
                 if 'update_status' in result:
                     return_values['update_status'] = result['update_status']
+            elif command == "MultipartHTTPPushUpdate":
+                result = rf_utils.multipath_http_push_update(update_opts)
+                if 'update_status' in result:
+                    return_values['update_status'] = result['update_status']
             elif command == "PerformRequestedOperations":
                 result = rf_utils.perform_requested_update_operations(update_opts['update_handle'])
 


Test patch: