diff --git a/changelogs/fragments/6471-redfish-add-multipart-http-push-command.yml b/changelogs/fragments/6471-redfish-add-multipart-http-push-command.yml new file mode 100644 index 0000000000..dd863638d0 --- /dev/null +++ b/changelogs/fragments/6471-redfish-add-multipart-http-push-command.yml @@ -0,0 +1,2 @@ +minor_changes: + - redfish_command - add ``MultipartHTTPPushUpdate`` command (https://github.com/ansible-collections/community.general/issues/6471, https://github.com/ansible-collections/community.general/pull/6612). diff --git a/plugins/module_utils/redfish_utils.py b/plugins/module_utils/redfish_utils.py index 9b64703027..027ce56308 100644 --- a/plugins/module_utils/redfish_utils.py +++ b/plugins/module_utils/redfish_utils.py @@ -7,9 +7,14 @@ from __future__ import absolute_import, division, print_function __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 @@ class RedfishUtils(object): '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 @@ class RedfishUtils(object): # 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 @@ class RedfishUtils(object): '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 @@ class RedfishUtils(object): '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 index 400677eabb..715d06fcad 100644 --- a/plugins/modules/redfish_command.py +++ b/plugins/modules/redfish_command.py @@ -137,6 +137,12 @@ options: - 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 @@ EXAMPLES = ''' 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 @@ CATEGORY_COMMANDS_ALL = { "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'])