1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2024-09-14 20:13:21 +02:00

Redfish: Add MultipartHTTPPushUpdate (#6612)

* Redfish: Add MultipartHTTPPushUpdate

Signed-off-by: Mike Raineri <michael.raineri@dell.com>

* Updates based on CI results

Signed-off-by: Mike Raineri <michael.raineri@dell.com>

* Update plugins/modules/redfish_command.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update changelogs/fragments/6471-redfish-add-multipart-http-push-command.yml

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/modules/redfish_command.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/module_utils/redfish_utils.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/module_utils/redfish_utils.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/module_utils/redfish_utils.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/module_utils/redfish_utils.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Added missing import statement

Signed-off-by: Mike Raineri <michael.raineri@dell.com>

* Added documentation for the usage of 'timeout'

Signed-off-by: Mike Raineri <michael.raineri@dell.com>

---------

Signed-off-by: Mike Raineri <michael.raineri@dell.com>
Co-authored-by: Felix Fontein <felix@fontein.de>
This commit is contained in:
Mike Raineri 2023-06-05 15:56:44 -04:00 committed by GitHub
parent 36e8653cf7
commit c4e7a943c0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 161 additions and 3 deletions

View file

@ -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).

View file

@ -7,9 +7,14 @@ from __future__ import absolute_import, division, print_function
__metaclass__ = type __metaclass__ = type
import json import json
import os
import random
import string
from ansible.module_utils.urls import open_url 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_native
from ansible.module_utils.common.text.converters import to_text 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 import http_client
from ansible.module_utils.six.moves.urllib.error import URLError, HTTPError from ansible.module_utils.six.moves.urllib.error import URLError, HTTPError
from ansible.module_utils.six.moves.urllib.parse import urlparse 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))} 'msg': "Failed GET request to '%s': '%s'" % (uri, to_text(e))}
return {'ret': True, 'data': data, 'headers': headers, 'resp': resp} 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) req_headers = dict(POST_HEADERS)
username, password, basic_auth = self._auth_params(req_headers) username, password, basic_auth = self._auth_params(req_headers)
try: try:
@ -162,7 +167,14 @@ class RedfishUtils(object):
# header since this can cause conflicts with some services # header since this can cause conflicts with some services
if self.sessions_uri is not None and uri == (self.root_uri + self.sessions_uri): if self.sessions_uri is not None and uri == (self.root_uri + self.sessions_uri):
basic_auth = False 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", headers=req_headers, method="POST",
url_username=username, url_password=password, url_username=username, url_password=password,
force_basic_auth=basic_auth, validate_certs=False, 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))} 'msg': "Failed DELETE request to '%s': '%s'" % (uri, to_text(e))}
return {'ret': True, 'resp': resp} 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 @staticmethod
def _get_extended_message(error): def _get_extended_message(error):
""" """
@ -1572,6 +1637,61 @@ class RedfishUtils(object):
'msg': "SimpleUpdate requested", 'msg': "SimpleUpdate requested",
'update_status': self._operation_results(response['resp'], response['data'])} '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): def get_update_status(self, update_handle):
""" """
Gets the status of an update operation. Gets the status of an update operation.

View file

@ -137,6 +137,12 @@ options:
- URI of the image for the update. - URI of the image for the update.
type: str type: str
version_added: '0.2.0' 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: update_protocol:
required: false required: false
description: description:
@ -541,6 +547,30 @@ EXAMPLES = '''
username: operator username: operator
password: supersecretpwd 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 - name: Perform requested operations to continue the update
community.general.redfish_command: community.general.redfish_command:
category: Update category: Update
@ -697,7 +727,7 @@ CATEGORY_COMMANDS_ALL = {
"Manager": ["GracefulRestart", "ClearLogs", "VirtualMediaInsert", "Manager": ["GracefulRestart", "ClearLogs", "VirtualMediaInsert",
"VirtualMediaEject", "PowerOn", "PowerForceOff", "PowerForceRestart", "VirtualMediaEject", "PowerOn", "PowerForceOff", "PowerForceRestart",
"PowerGracefulRestart", "PowerGracefulShutdown", "PowerReboot"], "PowerGracefulRestart", "PowerGracefulShutdown", "PowerReboot"],
"Update": ["SimpleUpdate", "PerformRequestedOperations"], "Update": ["SimpleUpdate", "MultipartHTTPPushUpdate", "PerformRequestedOperations"],
} }
@ -726,6 +756,7 @@ def main():
boot_override_mode=dict(choices=['Legacy', 'UEFI']), boot_override_mode=dict(choices=['Legacy', 'UEFI']),
resource_id=dict(), resource_id=dict(),
update_image_uri=dict(), update_image_uri=dict(),
update_image_file=dict(type='path'),
update_protocol=dict(), update_protocol=dict(),
update_targets=dict(type='list', elements='str', default=[]), update_targets=dict(type='list', elements='str', default=[]),
update_creds=dict( update_creds=dict(
@ -791,6 +822,7 @@ def main():
# update options # update options
update_opts = { update_opts = {
'update_image_uri': module.params['update_image_uri'], 'update_image_uri': module.params['update_image_uri'],
'update_image_file': module.params['update_image_file'],
'update_protocol': module.params['update_protocol'], 'update_protocol': module.params['update_protocol'],
'update_targets': module.params['update_targets'], 'update_targets': module.params['update_targets'],
'update_creds': module.params['update_creds'], 'update_creds': module.params['update_creds'],
@ -940,6 +972,10 @@ def main():
result = rf_utils.simple_update(update_opts) result = rf_utils.simple_update(update_opts)
if 'update_status' in result: if 'update_status' in result:
return_values['update_status'] = result['update_status'] 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": elif command == "PerformRequestedOperations":
result = rf_utils.perform_requested_update_operations(update_opts['update_handle']) result = rf_utils.perform_requested_update_operations(update_opts['update_handle'])