From 5c1c8152ec1c392e11fe1242dbbb24fe8ea3fdb8 Mon Sep 17 00:00:00 2001
From: Mike Raineri <mraineri@gmail.com>
Date: Wed, 23 Nov 2022 01:46:39 -0500
Subject: [PATCH] Redfish: Expanded SimpleUpdate command to allow for users to
 monitor the progress of an update and perform follow-up operations (#5580)

* Redfish: Expanded SimpleUpdate command to allow for users to monitor the progress of an update and perform follow-up operations

* Update changelogs/fragments/3910-redfish-add-operation-apply-time-to-simple-update.yml

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

* Update plugins/modules/redfish_command.py

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

* Update changelogs/fragments/4276-redfish-command-updates-for-full-simple-update-workflow.yml

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

* Updated based on feedback and CI results

* Update plugins/modules/redfish_command.py

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

* Update plugins/modules/redfish_command.py

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

* Update plugins/modules/redfish_info.py

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

Co-authored-by: Felix Fontein <felix@fontein.de>
---
 ...-operation-apply-time-to-simple-update.yml |   2 +
 ...pdates-for-full-simple-update-workflow.yml |   4 +
 plugins/module_utils/redfish_utils.py         | 136 +++++++++++++++++-
 plugins/modules/redfish_command.py            |  58 +++++++-
 plugins/modules/redfish_info.py               |  26 +++-
 5 files changed, 218 insertions(+), 8 deletions(-)
 create mode 100644 changelogs/fragments/3910-redfish-add-operation-apply-time-to-simple-update.yml
 create mode 100644 changelogs/fragments/4276-redfish-command-updates-for-full-simple-update-workflow.yml

diff --git a/changelogs/fragments/3910-redfish-add-operation-apply-time-to-simple-update.yml b/changelogs/fragments/3910-redfish-add-operation-apply-time-to-simple-update.yml
new file mode 100644
index 0000000000..d52438ca45
--- /dev/null
+++ b/changelogs/fragments/3910-redfish-add-operation-apply-time-to-simple-update.yml
@@ -0,0 +1,2 @@
+minor_changes:
+  - redfish_command - add ``update_apply_time`` to ``SimpleUpdate`` command (https://github.com/ansible-collections/community.general/issues/3910).
diff --git a/changelogs/fragments/4276-redfish-command-updates-for-full-simple-update-workflow.yml b/changelogs/fragments/4276-redfish-command-updates-for-full-simple-update-workflow.yml
new file mode 100644
index 0000000000..2f5da1467b
--- /dev/null
+++ b/changelogs/fragments/4276-redfish-command-updates-for-full-simple-update-workflow.yml
@@ -0,0 +1,4 @@
+minor_changes:
+  - redfish_command - add ``update_status`` to output of ``SimpleUpdate`` command to allow a user monitor the update in progress (https://github.com/ansible-collections/community.general/issues/4276).
+  - redfish_info - add ``GetUpdateStatus`` command to check the progress of a previous update request (https://github.com/ansible-collections/community.general/issues/4276).
+  - redfish_command - add ``PerformRequestedOperations`` command to perform any operations necessary to continue the update flow (https://github.com/ansible-collections/community.general/issues/4276).
diff --git a/plugins/module_utils/redfish_utils.py b/plugins/module_utils/redfish_utils.py
index 3bd3d73676..a86baa1066 100644
--- a/plugins/module_utils/redfish_utils.py
+++ b/plugins/module_utils/redfish_utils.py
@@ -143,7 +143,7 @@ class RedfishUtils(object):
         except Exception as e:
             return {'ret': False,
                     'msg': "Failed GET request to '%s': '%s'" % (uri, to_text(e))}
-        return {'ret': True, 'data': data, 'headers': headers}
+        return {'ret': True, 'data': data, 'headers': headers, 'resp': resp}
 
     def post_request(self, uri, pyld):
         req_headers = dict(POST_HEADERS)
@@ -155,6 +155,11 @@ class RedfishUtils(object):
                             force_basic_auth=basic_auth, validate_certs=False,
                             follow_redirects='all',
                             use_proxy=True, timeout=self.timeout)
+            try:
+                data = json.loads(to_native(resp.read()))
+            except Exception as e:
+                # No response data; this is okay in many cases
+                data = None
             headers = dict((k.lower(), v) for (k, v) in resp.info().items())
         except HTTPError as e:
             msg = self._get_extended_message(e)
@@ -169,7 +174,7 @@ class RedfishUtils(object):
         except Exception as e:
             return {'ret': False,
                     'msg': "Failed POST request to '%s': '%s'" % (uri, to_text(e))}
-        return {'ret': True, 'headers': headers, 'resp': resp}
+        return {'ret': True, 'data': data, 'headers': headers, 'resp': resp}
 
     def patch_request(self, uri, pyld, check_pyld=False):
         req_headers = dict(PATCH_HEADERS)
@@ -1384,11 +1389,82 @@ class RedfishUtils(object):
         else:
             return self._software_inventory(self.software_uri)
 
+    def _operation_results(self, response, data, handle=None):
+        """
+        Builds the results for an operation from task, job, or action response.
+
+        :param response: HTTP response object
+        :param data: HTTP response data
+        :param handle: The task or job handle that was last used
+        :return: dict containing operation results
+        """
+
+        operation_results = {'status': None, 'messages': [], 'handle': None, 'ret': True,
+                             'resets_requested': []}
+
+        if response.status == 204:
+            # No content; successful, but nothing to return
+            # Use the Redfish "Completed" enum from TaskState for the operation status
+            operation_results['status'] = 'Completed'
+        else:
+            # Parse the response body for details
+
+            # Determine the next handle, if any
+            operation_results['handle'] = handle
+            if response.status == 202:
+                # Task generated; get the task monitor URI
+                operation_results['handle'] = response.getheader('Location', handle)
+
+            # Pull out the status and messages based on the body format
+            if data is not None:
+                response_type = data.get('@odata.type', '')
+                if response_type.startswith('#Task.') or response_type.startswith('#Job.'):
+                    # Task and Job have similar enough structures to treat the same
+                    operation_results['status'] = data.get('TaskState', data.get('JobState'))
+                    operation_results['messages'] = data.get('Messages', [])
+                else:
+                    # Error response body, which is a bit of a misnomer since it's used in successful action responses
+                    operation_results['status'] = 'Completed'
+                    if response.status >= 400:
+                        operation_results['status'] = 'Exception'
+                    operation_results['messages'] = data.get('error', {}).get('@Message.ExtendedInfo', [])
+            else:
+                # No response body (or malformed); build based on status code
+                operation_results['status'] = 'Completed'
+                if response.status == 202:
+                    operation_results['status'] = 'New'
+                elif response.status >= 400:
+                    operation_results['status'] = 'Exception'
+
+            # Clear out the handle if the operation is complete
+            if operation_results['status'] in ['Completed', 'Cancelled', 'Exception', 'Killed']:
+                operation_results['handle'] = None
+
+            # Scan the messages to see if next steps are needed
+            for message in operation_results['messages']:
+                message_id = message['MessageId']
+
+                if message_id.startswith('Update.1.') and message_id.endswith('.OperationTransitionedToJob'):
+                    # Operation rerouted to a job; update the status and handle
+                    operation_results['status'] = 'New'
+                    operation_results['handle'] = message['MessageArgs'][0]
+                    operation_results['resets_requested'] = []
+                    # No need to process other messages in this case
+                    break
+
+                if message_id.startswith('Base.1.') and message_id.endswith('.ResetRequired'):
+                    # A reset to some device is needed to continue the update
+                    reset = {'uri': message['MessageArgs'][0], 'type': message['MessageArgs'][1]}
+                    operation_results['resets_requested'].append(reset)
+
+        return operation_results
+
     def simple_update(self, update_opts):
         image_uri = update_opts.get('update_image_uri')
         protocol = update_opts.get('update_protocol')
         targets = update_opts.get('update_targets')
         creds = update_opts.get('update_creds')
+        apply_time = update_opts.get('update_apply_time')
 
         if not image_uri:
             return {'ret': False, 'msg':
@@ -1439,11 +1515,65 @@ class RedfishUtils(object):
                 payload["Username"] = creds.get('username')
             if creds.get('password'):
                 payload["Password"] = creds.get('password')
+        if apply_time:
+            payload["@Redfish.OperationApplyTime"] = apply_time
         response = self.post_request(self.root_uri + update_uri, payload)
         if response['ret'] is False:
             return response
         return {'ret': True, 'changed': True,
-                'msg': "SimpleUpdate requested"}
+                'msg': "SimpleUpdate requested",
+                'update_status': self._operation_results(response['resp'], response['data'])}
+
+    def get_update_status(self, update_handle):
+        """
+        Gets the status of an update operation.
+
+        :param handle: The task or job handle tracking the update
+        :return: dict containing the response of the update status
+        """
+
+        if not update_handle:
+            return {'ret': False, 'msg': 'Must provide a handle tracking the update.'}
+
+        # Get the task or job tracking the update
+        response = self.get_request(self.root_uri + update_handle)
+        if response['ret'] is False:
+            return response
+
+        # Inspect the response to build the update status
+        return self._operation_results(response['resp'], response['data'], update_handle)
+
+    def perform_requested_update_operations(self, update_handle):
+        """
+        Performs requested operations to allow the update to continue.
+
+        :param handle: The task or job handle tracking the update
+        :return: dict containing the result of the operations
+        """
+
+        # Get the current update status
+        update_status = self.get_update_status(update_handle)
+        if update_status['ret'] is False:
+            return update_status
+
+        changed = False
+
+        # Perform any requested updates
+        for reset in update_status['resets_requested']:
+            resp = self.post_request(self.root_uri + reset['uri'], {'ResetType': reset['type']})
+            if resp['ret'] is False:
+                # Override the 'changed' indicator since other resets may have
+                # been successful
+                resp['changed'] = changed
+                return resp
+            changed = True
+
+        msg = 'No operations required for the update'
+        if changed:
+            # Will need to consider finetuning this message if the scope of the
+            # requested operations grow over time
+            msg = 'One or more components reset to continue the update'
+        return {'ret': True, 'changed': changed, 'msg': msg}
 
     def get_bios_attributes(self, systems_uri):
         result = {}
diff --git a/plugins/modules/redfish_command.py b/plugins/modules/redfish_command.py
index 43443cf38e..9d5640996a 100644
--- a/plugins/modules/redfish_command.py
+++ b/plugins/modules/redfish_command.py
@@ -161,6 +161,24 @@ options:
         description:
           - Password for retrieving the update image.
         type: str
+  update_apply_time:
+    required: false
+    description:
+      - Time when to apply the update.
+    type: str
+    choices:
+      - Immediate
+      - OnReset
+      - AtMaintenanceWindowStart
+      - InMaintenanceWindowOnReset
+      - OnStartUpdateRequest
+    version_added: '6.1.0'
+  update_handle:
+    required: false
+    description:
+      - Handle to check the status of an update in progress.
+    type: str
+    version_added: '6.1.0'
   virtual_media:
     required: false
     description:
@@ -508,6 +526,15 @@ EXAMPLES = '''
         username: operator
         password: supersecretpwd
 
+  - name: Perform requested operations to continue the update
+    community.general.redfish_command:
+      category: Update
+      command: PerformRequestedOperations
+      baseuri: "{{ baseuri }}"
+      username: "{{ username }}"
+      password: "{{ password }}"
+      update_handle: /redfish/v1/TaskService/TaskMonitors/735
+
   - name: Insert Virtual Media
     community.general.redfish_command:
       category: Systems
@@ -610,6 +637,20 @@ msg:
     returned: always
     type: str
     sample: "Action was successful"
+return_values:
+    description: Dictionary containing command-specific response data from the action.
+    returned: on success
+    type: dict
+    version_added: 6.1.0
+    sample: {
+        "update_status": {
+            "handle": "/redfish/v1/TaskService/TaskMonitors/735",
+            "messages": [],
+            "resets_requested": [],
+            "ret": true,
+            "status": "New"
+        }
+    }
 '''
 
 from ansible.module_utils.basic import AnsibleModule
@@ -630,12 +671,13 @@ CATEGORY_COMMANDS_ALL = {
     "Manager": ["GracefulRestart", "ClearLogs", "VirtualMediaInsert",
                 "VirtualMediaEject", "PowerOn", "PowerForceOff", "PowerForceRestart",
                 "PowerGracefulRestart", "PowerGracefulShutdown", "PowerReboot"],
-    "Update": ["SimpleUpdate"]
+    "Update": ["SimpleUpdate", "PerformRequestedOperations"],
 }
 
 
 def main():
     result = {}
+    return_values = {}
     module = AnsibleModule(
         argument_spec=dict(
             category=dict(required=True),
@@ -667,6 +709,9 @@ def main():
                     password=dict(no_log=True)
                 )
             ),
+            update_apply_time=dict(choices=['Immediate', 'OnReset', 'AtMaintenanceWindowStart',
+                                            'InMaintenanceWindowOnReset', 'OnStartUpdateRequest']),
+            update_handle=dict(),
             virtual_media=dict(
                 type='dict',
                 options=dict(
@@ -721,7 +766,9 @@ def main():
         'update_image_uri': module.params['update_image_uri'],
         'update_protocol': module.params['update_protocol'],
         'update_targets': module.params['update_targets'],
-        'update_creds': module.params['update_creds']
+        'update_creds': module.params['update_creds'],
+        'update_apply_time': module.params['update_apply_time'],
+        'update_handle': module.params['update_handle'],
     }
 
     # Boot override options
@@ -859,6 +906,10 @@ def main():
         for command in command_list:
             if command == "SimpleUpdate":
                 result = rf_utils.simple_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'])
 
     # Return data back or fail with proper message
     if result['ret'] is True:
@@ -866,7 +917,8 @@ def main():
         changed = result.get('changed', True)
         session = result.get('session', dict())
         module.exit_json(changed=changed, session=session,
-                         msg='Action was successful')
+                         msg='Action was successful',
+                         return_values=return_values)
     else:
         module.fail_json(msg=to_native(result['msg']))
 
diff --git a/plugins/modules/redfish_info.py b/plugins/modules/redfish_info.py
index fd81695368..e6df4813ad 100644
--- a/plugins/modules/redfish_info.py
+++ b/plugins/modules/redfish_info.py
@@ -58,6 +58,12 @@ options:
       - Timeout in seconds for HTTP requests to OOB controller.
     default: 10
     type: int
+  update_handle:
+    required: false
+    description:
+      - Handle to check the status of an update in progress.
+    type: str
+    version_added: '6.1.0'
 
 author: "Jose Delarosa (@jose-delarosa)"
 '''
@@ -247,6 +253,15 @@ EXAMPLES = '''
       username: "{{ username }}"
       password: "{{ password }}"
 
+  - name: Get the status of an update operation
+    community.general.redfish_info:
+      category: Update
+      command: GetUpdateStatus
+      baseuri: "{{ baseuri }}"
+      username: "{{ username }}"
+      password: "{{ password }}"
+      update_handle: /redfish/v1/TaskService/TaskMonitors/735
+
   - name: Get Manager Services
     community.general.redfish_info:
       category: Manager
@@ -324,7 +339,8 @@ CATEGORY_COMMANDS_ALL = {
                 "GetChassisThermals", "GetChassisInventory", "GetHealthReport"],
     "Accounts": ["ListUsers"],
     "Sessions": ["GetSessions"],
-    "Update": ["GetFirmwareInventory", "GetFirmwareUpdateCapabilities", "GetSoftwareInventory"],
+    "Update": ["GetFirmwareInventory", "GetFirmwareUpdateCapabilities", "GetSoftwareInventory",
+               "GetUpdateStatus"],
     "Manager": ["GetManagerNicInventory", "GetVirtualMedia", "GetLogs", "GetNetworkProtocols",
                 "GetHealthReport", "GetHostInterfaces", "GetManagerInventory"],
 }
@@ -350,7 +366,8 @@ def main():
             username=dict(),
             password=dict(no_log=True),
             auth_token=dict(no_log=True),
-            timeout=dict(type='int', default=10)
+            timeout=dict(type='int', default=10),
+            update_handle=dict(),
         ),
         required_together=[
             ('username', 'password'),
@@ -372,6 +389,9 @@ def main():
     # timeout
     timeout = module.params['timeout']
 
+    # update handle
+    update_handle = module.params['update_handle']
+
     # Build root URI
     root_uri = "https://" + module.params['baseuri']
     rf_utils = RedfishUtils(creds, root_uri, timeout, module)
@@ -482,6 +502,8 @@ def main():
                     result["software"] = rf_utils.get_software_inventory()
                 elif command == "GetFirmwareUpdateCapabilities":
                     result["firmware_update_capabilities"] = rf_utils.get_firmware_update_capabilities()
+                elif command == "GetUpdateStatus":
+                    result["update_status"] = rf_utils.get_update_status(update_handle)
 
         elif category == "Sessions":
             # execute only if we find SessionService resources