diff --git a/src/core/src/core_logic/ExecutionConfig.py b/src/core/src/core_logic/ExecutionConfig.py index 905e871d..9f4ef958 100644 --- a/src/core/src/core_logic/ExecutionConfig.py +++ b/src/core/src/core_logic/ExecutionConfig.py @@ -74,10 +74,11 @@ def __init__(self, env_layer, composite_logger, execution_parameters): # Derived Settings self.log_file_path = os.path.join(self.log_folder, str(self.sequence_number) + ".core.log") + self.complete_status_file_path = os.path.join(self.status_folder, str(self.sequence_number) + ".complete" + ".status") self.status_file_path = os.path.join(self.status_folder, str(self.sequence_number) + ".status") self.include_assessment_with_configure_patching = (self.operation == Constants.CONFIGURE_PATCHING and self.assessment_mode == Constants.AssessmentModes.AUTOMATIC_BY_PLATFORM) - self.composite_logger.log_debug(" - Derived execution-config settings. [CoreLog={0}][StatusFile={1}][IncludeAssessmentWithConfigurePatching={2}]" - .format(str(self.log_file_path), str(self.status_file_path), self.include_assessment_with_configure_patching)) + self.composite_logger.log_debug(" - Derived execution-config settings. [CoreLog={0}][CompleteStatusFile={1}][StatusFile={2}][IncludeAssessmentWithConfigurePatching={3}]" + .format(str(self.log_file_path), str(self.complete_status_file_path), str(self.status_file_path), self.include_assessment_with_configure_patching)) # Auto assessment overrides if self.exec_auto_assess_only: diff --git a/src/core/src/service_interfaces/StatusHandler.py b/src/core/src/service_interfaces/StatusHandler.py index 1abfa550..f5338f06 100644 --- a/src/core/src/service_interfaces/StatusHandler.py +++ b/src/core/src/service_interfaces/StatusHandler.py @@ -32,6 +32,7 @@ def __init__(self, env_layer, execution_config, composite_logger, telemetry_writ self.execution_config = execution_config self.composite_logger = composite_logger self.telemetry_writer = telemetry_writer # not used immediately but need to know if there are issues persisting status + self.complete_status_file_path = self.execution_config.complete_status_file_path self.status_file_path = self.execution_config.status_file_path self.__log_file_path = self.execution_config.log_file_path self.vm_cloud_type = vm_cloud_type @@ -322,8 +323,8 @@ def set_assessment_substatus_json(self, status=Constants.STATUS_TRANSITIONING, c # Wrap assessment summary into assessment substatus self.__assessment_substatus_json = self.__new_substatus_json_for_operation(Constants.PATCH_ASSESSMENT_SUMMARY, status, code, json.dumps(self.__assessment_summary_json)) - # Update status on disk - self.__write_status_file() + # Update complete status on disk + self.__write_complete_status_file() def __new_assessment_summary_json(self, assessment_packages_json, status, code): """ Called by: set_assessment_substatus_json @@ -371,8 +372,8 @@ def set_installation_substatus_json(self, status=Constants.STATUS_TRANSITIONING, # Wrap deployment summary into installation substatus self.__installation_substatus_json = self.__new_substatus_json_for_operation(Constants.PATCH_INSTALLATION_SUMMARY, status, code, json.dumps(self.__installation_summary_json)) - # Update status on disk - self.__write_status_file() + # Update complete status on disk + self.__write_complete_status_file() def __new_installation_summary_json(self, installation_packages_json): """ Called by: set_installation_substatus_json @@ -433,8 +434,8 @@ def set_patch_metadata_for_healthstore_substatus_json(self, status=Constants.STA # Wrap healthstore summary into healthstore substatus self.__metadata_for_healthstore_substatus_json = self.__new_substatus_json_for_operation(Constants.PATCH_METADATA_FOR_HEALTHSTORE, status, code, json.dumps(self.__metadata_for_healthstore_summary_json)) - # Update status on disk - self.__write_status_file() + # Update complete status on disk + self.__write_complete_status_file() # wait period required in cases where we need to ensure HealthStore reads the status from GA if wait_after_update: @@ -466,8 +467,8 @@ def set_configure_patching_substatus_json(self, status=Constants.STATUS_TRANSITI # Wrap configure patching summary into configure patching substatus self.__configure_patching_substatus_json = self.__new_substatus_json_for_operation(Constants.CONFIGURE_PATCHING_SUMMARY, status, code, json.dumps(self.__configure_patching_summary_json)) - # Update status on disk - self.__write_status_file() + # Update complete status on disk + self.__write_complete_status_file() def __new_configure_patching_summary_json(self, automatic_os_patch_state, auto_assessment_state, status, code): """ Called by: set_configure_patching_substatus_json @@ -507,7 +508,11 @@ def __new_substatus_json_for_operation(operation_name, status="Transitioning", c # region - Status generation def __reset_status_file(self): - self.env_layer.file_system.write_with_retry(self.status_file_path, '[{0}]'.format(json.dumps(self.__new_basic_status_json())), mode='w+') + status_file_reset_content = json.dumps(self.__new_basic_status_json()) + # Create complete status template + self.env_layer.file_system.write_with_retry(self.complete_status_file_path, '[{0}]'.format(status_file_reset_content), mode='w+') + # Create agent-facing status template + self.env_layer.file_system.write_with_retry(self.status_file_path, '[{0}]'.format(status_file_reset_content), mode='w+') def __new_basic_status_json(self): return { @@ -566,44 +571,28 @@ def load_status_file_components(self, initial_load=False): self.composite_logger.log_debug("Loading status file components [InitialLoad={0}].".format(str(initial_load))) # Verify the status file exists - if not, reset status file - if not os.path.exists(self.status_file_path) and initial_load: + if not os.path.exists(self.complete_status_file_path) and initial_load: self.composite_logger.log_warning("Status file not found at initial load. Resetting status file to defaults.") self.__reset_status_file() return - # Read the status file - raise exception on persistent failure - for i in range(0, Constants.MAX_FILE_OPERATION_RETRY_COUNT): - try: - with self.env_layer.file_system.open(self.status_file_path, 'r') as file_handle: - status_file_data_raw = json.load(file_handle)[0] # structure is array of 1 - except Exception as error: - if i < Constants.MAX_FILE_OPERATION_RETRY_COUNT - 1: - time.sleep(i + 1) - else: - self.composite_logger.log_error("Unable to read status file (retries exhausted). Error: {0}.".format(repr(error))) - raise - # Load status data and sanity check structure - raise exception if data loss risk is detected on corrupt data - try: - status_file_data = status_file_data_raw - if 'status' not in status_file_data or 'substatus' not in status_file_data['status']: - self.composite_logger.log_error("Malformed status file. Resetting status file for safety.") - self.__reset_status_file() - return - except Exception as error: - self.composite_logger.log_error("Unable to load status file json. Error: {0}; Data: {1}".format(repr(error), str(status_file_data_raw))) - raise + complete_status_file_data = self.__load_complete_status_file_data(self.complete_status_file_path) + if 'status' not in complete_status_file_data or 'substatus' not in complete_status_file_data['status']: + self.composite_logger.log_error("Malformed status file. Resetting status file for safety.") + self.__reset_status_file() + return # Load portions of data that need to be built on for next write - raise exception if corrupt data is encountered # todo: refactor - self.__high_level_status_message = status_file_data['status']['formattedMessage']['message'] - for i in range(0, len(status_file_data['status']['substatus'])): - name = status_file_data['status']['substatus'][i]['name'] + self.__high_level_status_message = complete_status_file_data['status']['formattedMessage']['message'] + for i in range(0, len(complete_status_file_data['status']['substatus'])): + name = complete_status_file_data['status']['substatus'][i]['name'] if name == Constants.PATCH_INSTALLATION_SUMMARY: # if it exists, it must be to spec, or an exception will get thrown if self.execution_config.exec_auto_assess_only: - self.__installation_substatus_json = status_file_data['status']['substatus'][i] + self.__installation_substatus_json = complete_status_file_data['status']['substatus'][i] else: - message = status_file_data['status']['substatus'][i]['formattedMessage']['message'] + message = complete_status_file_data['status']['substatus'][i]['formattedMessage']['message'] self.__installation_summary_json = json.loads(message) self.__installation_packages_map = OrderedDict((package["patchId"], package) for package in self.__installation_summary_json['patches']) self.__installation_packages = list(self.__installation_packages_map.values()) @@ -614,7 +603,7 @@ def load_status_file_components(self, initial_load=False): self.__installation_errors = errors['details'] self.__installation_total_error_count = self.__get_total_error_count_from_prev_status(errors['message']) if name == Constants.PATCH_ASSESSMENT_SUMMARY: # if it exists, it must be to spec, or an exception will get thrown - message = status_file_data['status']['substatus'][i]['formattedMessage']['message'] + message = complete_status_file_data['status']['substatus'][i]['formattedMessage']['message'] self.__assessment_summary_json = json.loads(message) self.__assessment_packages_map = OrderedDict((package["patchId"], package) for package in self.__assessment_summary_json['patches']) self.__assessment_packages = list(self.__assessment_packages_map.values()) @@ -624,22 +613,36 @@ def load_status_file_components(self, initial_load=False): self.__assessment_total_error_count = self.__get_total_error_count_from_prev_status(errors['message']) if name == Constants.PATCH_METADATA_FOR_HEALTHSTORE: # if it exists, it must be to spec, or an exception will get thrown if self.execution_config.exec_auto_assess_only: - self.__metadata_for_healthstore_substatus_json = status_file_data['status']['substatus'][i] + self.__metadata_for_healthstore_substatus_json = complete_status_file_data['status']['substatus'][i] else: - message = status_file_data['status']['substatus'][i]['formattedMessage']['message'] + message = complete_status_file_data['status']['substatus'][i]['formattedMessage']['message'] self.__metadata_for_healthstore_summary_json = json.loads(message) if name == Constants.CONFIGURE_PATCHING_SUMMARY: # if it exists, it must be to spec, or an exception will get thrown if self.execution_config.exec_auto_assess_only: - self.__configure_patching_substatus_json = status_file_data['status']['substatus'][i] + self.__configure_patching_substatus_json = complete_status_file_data['status']['substatus'][i] else: - message = status_file_data['status']['substatus'][i]['formattedMessage']['message'] + message = complete_status_file_data['status']['substatus'][i]['formattedMessage']['message'] self.__configure_patching_summary_json = json.loads(message) errors = self.__configure_patching_summary_json['errors'] if errors is not None and errors['details'] is not None: self.__configure_patching_errors = errors['details'] self.__configure_patching_top_level_error_count = self.__get_total_error_count_from_prev_status(errors['message']) - def __write_status_file(self): + def __load_complete_status_file_data(self, file_path): + # Read the status file - raise exception on persistent failure + for i in range(0, Constants.MAX_FILE_OPERATION_RETRY_COUNT): + try: + with self.env_layer.file_system.open(file_path, 'r') as file_handle: + complete_status_file_data = json.load(file_handle)[0] # structure is array of 1 + except Exception as error: + if i < Constants.MAX_FILE_OPERATION_RETRY_COUNT - 1: + time.sleep(i + 1) + else: + self.composite_logger.log_error("Unable to read status file (retries exhausted). Error: {0}.".format(repr(error))) + raise + return complete_status_file_data + + def __write_complete_status_file(self): """ Composes and writes the status file from **already up-to-date** in-memory data. This is usually the final call to compose and persist after an in-memory data update in a specialized method. @@ -676,22 +679,26 @@ def __write_status_file(self): :return: None """ - status_file_payload = self.__new_basic_status_json() - status_file_payload['status']['formattedMessage']['message'] = str(self.__high_level_status_message) + complete_status_payload = self.__new_basic_status_json() + complete_status_payload['status']['formattedMessage']['message'] = str(self.__high_level_status_message) if self.__assessment_substatus_json is not None: - status_file_payload['status']['substatus'].append(self.__assessment_substatus_json) + complete_status_payload['status']['substatus'].append(self.__assessment_substatus_json) if self.__installation_substatus_json is not None: - status_file_payload['status']['substatus'].append(self.__installation_substatus_json) + complete_status_payload['status']['substatus'].append(self.__installation_substatus_json) if self.__metadata_for_healthstore_substatus_json is not None: - status_file_payload['status']['substatus'].append(self.__metadata_for_healthstore_substatus_json) + complete_status_payload['status']['substatus'].append(self.__metadata_for_healthstore_substatus_json) if self.__configure_patching_substatus_json is not None: - status_file_payload['status']['substatus'].append(self.__configure_patching_substatus_json) - if os.path.isdir(self.status_file_path): + complete_status_payload['status']['substatus'].append(self.__configure_patching_substatus_json) + if os.path.isdir(self.complete_status_file_path): self.composite_logger.log_error("Core state file path returned a directory. Attempting to reset.") - shutil.rmtree(self.status_file_path) + shutil.rmtree(self.complete_status_file_path) + + # Write complete status file .complete.status + self.env_layer.file_system.write_with_retry_using_temp_file(self.complete_status_file_path, '[{0}]'.format(json.dumps(complete_status_payload)), mode='w+') - self.env_layer.file_system.write_with_retry_using_temp_file(self.status_file_path, '[{0}]'.format(json.dumps(status_file_payload)), mode='w+') + # Write agent status file + self.env_layer.file_system.write_with_retry_using_temp_file(self.status_file_path, '[{0}]'.format(json.dumps(complete_status_payload)), mode='w+') # endregion # region - Error objects @@ -799,3 +806,4 @@ def __set_errors_json(self, error_count_by_operation, errors_by_operation): "message": message } # endregion + diff --git a/src/core/tests/Test_StatusHandler.py b/src/core/tests/Test_StatusHandler.py index a686c592..75a5f7de 100644 --- a/src/core/tests/Test_StatusHandler.py +++ b/src/core/tests/Test_StatusHandler.py @@ -397,6 +397,30 @@ def test_sort_packages_by_classification_and_state(self): self.assertEqual(installation_patches_sorted[12]["name"], "test-package-2") # | Other | Excluded | self.assertEqual(installation_patches_sorted[13]["name"], "test-package-1") # | Other | NotSelected | + def test_if_status_file_resets_on_load_if_malformed(self): + # Mock complete status file with malformed json + sample_json = '[{"version": 1.0, "timestampUTC": "2023-05-13T07:38:07Z", "statusx": {"name": "Azure Patch Management", "operation": "Installation", "status": "success", "code": 0, "formattedMessage": {"lang": "en-US", "message": ""}, "substatusx": []}}]' + file_path = self.runtime.execution_config.status_folder + example_file1 = os.path.join(file_path, '123.complete.status') + self.runtime.execution_config.complete_status_file_path = example_file1 + + with open(example_file1, 'w') as f: + f.write(sample_json) + + status_handler = StatusHandler(self.runtime.env_layer, self.runtime.execution_config, self.runtime.composite_logger, self.runtime.telemetry_writer, self.runtime.vm_cloud_type) + # Mock complete status file with malformed json and being called in the load_status_file_components, and it will recreate a good complete_status_file + with self.runtime.env_layer.file_system.open(self.runtime.execution_config.status_file_path, 'r') as file_handle: + substatus_file_data = json.load(file_handle)[0] + self.assertEqual(substatus_file_data["status"]["name"], "Azure Patch Management") + self.assertEqual(substatus_file_data["status"]["operation"], "Installation") + self.assertIsNotNone(substatus_file_data["status"]["substatus"]) + self.assertEqual(len(substatus_file_data["status"]["substatus"]), 0) + self.runtime.env_layer.file_system.delete_files_from_dir(example_file1, "*.complete.status") + + def test_if_complete_status_path_is_dir(self): + self.runtime.execution_config.status_file_path = self.runtime.execution_config.status_folder + self.assertRaises(Exception, self.runtime.status_handler.load_status_file_components(initial_load=True)) + def test_assessment_packages_map(self): patch_count_for_test = 5 expected_patch_id = 'python-samba0_2:4.4.5+dfsg-2ubuntu5.4_Ubuntu_16.04' @@ -426,15 +450,13 @@ def test_installation_packages_map(self): patch_id_critical = 'python-samba0_2:4.4.5+dfsg-2ubuntu5.4_Ubuntu_16.04' expected_value_critical = {'version': '2:4.4.5+dfsg-2ubuntu5.4', 'classifications': ['Critical'], 'name': 'python-samba0', 'patchId': 'python-samba0_2:4.4.5+dfsg-2ubuntu5.4_Ubuntu_16.04', 'patchInstallationState': 'Installed'} - - status_handler = StatusHandler(self.runtime.env_layer, self.runtime.execution_config, self.runtime.composite_logger, self.runtime.telemetry_writer, self.runtime.vm_cloud_type) self.runtime.execution_config.operation = Constants.INSTALLATION self.runtime.status_handler.set_current_operation(Constants.INSTALLATION) patch_count_for_test = 50 test_packages, test_package_versions = self.__set_up_packages_func(patch_count_for_test) - status_handler.set_package_install_status(test_packages, test_package_versions, 'Installed', 'Other') + self.runtime.status_handler.set_package_install_status(test_packages, test_package_versions, 'Installed', 'Other') with self.runtime.env_layer.file_system.open(self.runtime.execution_config.status_file_path, 'r') as file_handle: substatus_file_data = json.load(file_handle)[0]["status"]["substatus"][0] @@ -446,7 +468,7 @@ def test_installation_packages_map(self): self.assertEqual(formatted_message['patches'][0]['name'], 'python-samba0') # Update the classification from Other to Critical - status_handler.set_package_install_status_classification(test_packages, test_package_versions, 'Critical') + self.runtime.status_handler.set_package_install_status_classification(test_packages, test_package_versions, 'Critical') with self.runtime.env_layer.file_system.open(self.runtime.execution_config.status_file_path, 'r') as file_handle: substatus_file_data = json.load(file_handle)[0]["status"]["substatus"][0] @@ -496,7 +518,6 @@ def test_load_status_and_set_package_install_status(self): self.assertEqual('python-samba0_2:4.4.5+dfsg-2ubuntu5.4_Ubuntu_16.04', str(json.loads(substatus_file_data["formattedMessage"]["message"])["patches"][0]["patchId"])) self.assertTrue('Critical' in str(json.loads(substatus_file_data["formattedMessage"]["message"])["patches"][2]["classifications"])) - self.runtime.env_layer.file_system.delete_files_from_dir(self.runtime.status_handler.status_file_path, '*.complete.status') # Setup functions to populate packages and versions for truncation