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:
parent
36e8653cf7
commit
c4e7a943c0
3 changed files with 161 additions and 3 deletions
|
@ -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).
|
|
@ -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.
|
||||||
|
|
|
@ -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'])
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue