# Copyright 2018 The Chromium OS Authors. All rights reserved. # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. import datetime import logging import os import re import shutil import time import urlparse from autotest_lib.client.common_lib import error from autotest_lib.client.common_lib import utils from autotest_lib.client.common_lib.cros import kernel_utils from autotest_lib.client.cros.update_engine import update_engine_event _DEFAULT_RUN = utils.run _DEFAULT_COPY = shutil.copy class UpdateEngineUtil(object): """ Utility code shared between client and server update_engine autotests. All update_engine autotests inherit from either the client or server version of update_engine_test: client/cros/update_engine/update_engine_test.py server/cros/update_engine/update_engine_test.py These update_engine_test classes inherit from test and update_engine_util. For update_engine_util to work seamlessly, we need the client and server update_engine_tests to define _run() and _get_file() functions: server side: host.run and host.get_file client side: utils.run and shutil.copy """ # Update engine status lines. _PROGRESS = 'PROGRESS' _CURRENT_OP = 'CURRENT_OP' # Source version when we force an update. _FORCED_UPDATE = 'ForcedUpdate' # update_engine_client command _UPDATE_ENGINE_CLIENT_CMD = 'update_engine_client' # Update engine statuses. _UPDATE_STATUS_IDLE = 'UPDATE_STATUS_IDLE' _UPDATE_STATUS_CHECKING_FOR_UPDATE = 'UPDATE_STATUS_CHECKING_FOR_UPDATE' _UPDATE_STATUS_UPDATE_AVAILABLE = 'UPDATE_STATUS_UPDATE_AVAILABLE' _UPDATE_STATUS_DOWNLOADING = 'UPDATE_STATUS_DOWNLOADING' _UPDATE_STATUS_FINALIZING = 'UPDATE_STATUS_FINALIZING' _UPDATE_STATUS_UPDATED_NEED_REBOOT = 'UPDATE_STATUS_UPDATED_NEED_REBOOT' _UPDATE_STATUS_REPORTING_ERROR_EVENT = 'UPDATE_STATUS_REPORTING_ERROR_EVENT' # Files used by the tests. _UPDATE_ENGINE_LOG = '/var/log/update_engine.log' _UPDATE_ENGINE_LOG_DIR = '/var/log/update_engine/' _CUSTOM_LSB_RELEASE = '/mnt/stateful_partition/etc/lsb-release' _UPDATE_ENGINE_PREFS_DIR = '/var/lib/update_engine/prefs/' # Update engine prefs _UPDATE_CHECK_RESPONSE_HASH = 'update-check-response-hash' # Interrupt types supported in AU tests. _REBOOT_INTERRUPT = 'reboot' _SUSPEND_INTERRUPT = 'suspend' _NETWORK_INTERRUPT = 'network' _SUPPORTED_INTERRUPTS = [_REBOOT_INTERRUPT, _SUSPEND_INTERRUPT, _NETWORK_INTERRUPT] # Public key used to force update_engine to verify omaha response data on # test images. _IMAGE_PUBLIC_KEY = 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFxZE03Z25kNDNjV2ZRenlydDE2UQpESEUrVDB5eGcxOE9aTys5c2M4aldwakMxekZ0b01Gb2tFU2l1OVRMVXArS1VDMjc0ZitEeElnQWZTQ082VTVECkpGUlBYVXp2ZTF2YVhZZnFsalVCeGMrSlljR2RkNlBDVWw0QXA5ZjAyRGhrckduZi9ya0hPQ0VoRk5wbTUzZG8Kdlo5QTZRNUtCZmNnMUhlUTA4OG9wVmNlUUd0VW1MK2JPTnE1dEx2TkZMVVUwUnUwQW00QURKOFhtdzRycHZxdgptWEphRm1WdWYvR3g3K1RPbmFKdlpUZU9POUFKSzZxNlY4RTcrWlppTUljNUY0RU9zNUFYL2xaZk5PM1JWZ0cyCk83RGh6emErbk96SjNaSkdLNVI0V3daZHVobjlRUllvZ1lQQjBjNjI4NzhxWHBmMkJuM05wVVBpOENmL1JMTU0KbVFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==' # Screenshot names used by interrupt tests _BEFORE_INTERRUPT_FILENAME = 'before_interrupt.png' _AFTER_INTERRUPT_FILENAME = 'after_interrupt.png' def __init__(self, run_func=_DEFAULT_RUN, get_file=_DEFAULT_COPY): """ Initialize this class with _run() and _get_file() functions. @param run_func: the function to use to run commands on the client. Defaults for use by client tests, but can be overwritten to run remotely from a server test. @param get_file: the function to use when copying a file. Defaults for use by client tests. Called with (file, destination) syntax. """ self._set_util_functions(run_func, get_file) def _set_util_functions(self, run_func=_DEFAULT_RUN, get_file=_DEFAULT_COPY): """See __init__().""" self._run = run_func self._get_file = get_file def _wait_for_progress(self, progress): """ Waits until we reach the percentage passed as the input. @param progress: The progress [0.0 .. 1.0] we want to wait for. """ while True: completed = self._get_update_progress() logging.debug('Checking if %s is greater than %s', completed, progress) if completed >= progress: break time.sleep(1) def _is_update_started(self): """Checks if the update has started.""" status = self._get_update_engine_status() if status is None: return False return status[self._CURRENT_OP] in ( self._UPDATE_STATUS_DOWNLOADING, self._UPDATE_STATUS_FINALIZING) def _has_progress_stopped(self): """Checks that the update_engine progress has stopped moving.""" before = self._get_update_engine_status()[self._PROGRESS] for i in range(0, 10): if before != self._get_update_engine_status()[self._PROGRESS]: return False time.sleep(1) return True def _get_update_progress(self): """Returns the current payload downloaded progress.""" while True: status = self._get_update_engine_status() if not status: continue if self._is_update_engine_idle(status): err_str = self._get_last_error_string() raise error.TestFail('Update status was idle while trying to ' 'get download status. Last error: %s' % err_str) if self._is_update_engine_reporting_error(status): err_str = self._get_last_error_string() raise error.TestFail('Update status reported error: %s' % err_str) # When the update has moved to the final stages, update engine # displays progress as 0.0 but for our needs we will return 1.0 if self._is_update_finished_downloading(status): return 1.0 # If we call this right after reboot it may not be downloading yet. if status[self._CURRENT_OP] != self._UPDATE_STATUS_DOWNLOADING: time.sleep(1) continue return float(status[self._PROGRESS]) def _wait_for_update_to_fail(self): """Waits for the update to retry until failure.""" timeout_minutes = 20 timeout = time.time() + 60 * timeout_minutes while True: if self._check_update_engine_log_for_entry('Reached max attempts ', raise_error=False): logging.debug('Found log entry for failed update.') if self._is_update_engine_idle(): break time.sleep(1) self._get_update_engine_status() if time.time() > timeout: raise error.TestFail('Update did not fail as expected. Timeout' ': %d minutes.' % timeout_minutes) def _wait_for_update_to_complete(self, check_kernel_after_update=True): """ Wait for update status to reach NEED_REBOOT. @param check_kernel_after_update: True to also check kernel state after the update. """ self._wait_for_update_status(self._UPDATE_STATUS_UPDATED_NEED_REBOOT) if check_kernel_after_update: kernel_utils.verify_kernel_state_after_update( self._host if hasattr(self, '_host') else None) def _wait_for_update_status(self, status_to_wait_for): """ Wait for the update to reach a certain status. @param status_to_wait_for: a string of the update status to wait for. """ while True: status = self._get_update_engine_status() # During reboot, status will be None if status is not None: if self._is_update_engine_reporting_error(status): err_str = self._get_last_error_string() raise error.TestFail('Update status reported error: %s' % err_str) if self._is_update_engine_idle(status): err_str = self._get_last_error_string() raise error.TestFail('Update status was unexpectedly ' 'IDLE when we were waiting for the ' 'update to complete: %s' % err_str) if status[self._CURRENT_OP] == status_to_wait_for: break time.sleep(1) def _get_update_engine_status(self, timeout=3600, ignore_timeout=True): """ Gets a dictionary version of update_engine_client --status. @param timeout: How long to wait for the status to return. @param ignore_timeout: True to throw an exception if timeout occurs. @return Dictionary of values within update_engine_client --status. @raise: error.AutoservError if command times out """ status = self._run([self._UPDATE_ENGINE_CLIENT_CMD, '--status'], timeout=timeout, ignore_status=True, ignore_timeout=ignore_timeout) if status is None: return None logging.info(status) if status.exit_status != 0: return None status_dict = {} for line in status.stdout.splitlines(): entry = line.partition('=') status_dict[entry[0]] = entry[2] return status_dict def _check_update_engine_log_for_entry(self, entry, raise_error=False, err_str=None, update_engine_log=None): """ Checks for entries in the update_engine log. @param entry: String or tuple of strings to search for. @param raise_error: Fails tests if log doesn't contain entry. @param err_str: The error string to raise if we cannot find entry. @param update_engine_log: Update engine log string you want to search. If None, we will read from the current update engine log. @return Boolean if the update engine log contains the entry. """ if isinstance(entry, str): # Create a tuple of strings so we can iterate over it. entry = (entry,) if not update_engine_log: update_engine_log = self._get_update_engine_log() if all(msg in update_engine_log for msg in entry): return True if not raise_error: return False error_str = ('Did not find expected string(s) in update_engine log: ' '%s' % entry) logging.debug(error_str) raise error.TestFail(err_str if err_str else error_str) def _is_update_finished_downloading(self, status=None): """ Checks if the update has moved to the final stages. @param status: Output of _get_update_engine_status(). If None that function will be called here first. """ if status is None: status = self._get_update_engine_status() return status[self._CURRENT_OP] in [ self._UPDATE_STATUS_FINALIZING, self._UPDATE_STATUS_UPDATED_NEED_REBOOT] def _is_update_engine_idle(self, status=None): """ Checks if the update engine is idle. @param status: Output of _get_update_engine_status(). If None that function will be called here first. """ if status is None: status = self._get_update_engine_status() return status[self._CURRENT_OP] == self._UPDATE_STATUS_IDLE def _is_checking_for_update(self, status=None): """ Checks if the update status is still checking for an update. @param status: Output of _get_update_engine_status(). If None that function will be called here first. """ if status is None: status = self._get_update_engine_status() return status[self._CURRENT_OP] in ( self._UPDATE_STATUS_CHECKING_FOR_UPDATE, self._UPDATE_STATUS_UPDATE_AVAILABLE) def _is_update_engine_reporting_error(self, status=None): """ Checks if the update engine status reported an error. @param status: Output of _get_update_engine_status(). If None that function will be called here first. """ if status is None: status = self._get_update_engine_status() return (status[self._CURRENT_OP] == self._UPDATE_STATUS_REPORTING_ERROR_EVENT) def _update_continued_where_it_left_off(self, progress, reboot_interrupt=False): """ Checks that the update did not restart after an interruption. When testing a reboot interrupt we can do additional checks on the logs before and after reboot to see if the update resumed. @param progress: The progress the last time we checked. @param reboot_interrupt: True if we are doing a reboot interrupt test. @returns True if update continued. False if update restarted. """ completed = self._get_update_progress() logging.info('New value: %f, old value: %f', completed, progress) if completed >= progress: return True # Sometimes update_engine will continue an update but the first reported # progress won't be correct. So check the logs for resume info. if not reboot_interrupt or not self._check_update_engine_log_for_entry( 'Resuming an update that was previously started'): return False # Find the reported Completed and Resumed progress. pattern = ('(.*)/(.*) operations \((.*)%\), (.*)/(.*) bytes downloaded' ' \((.*)%\), overall progress (.*)%') before_pattern = 'Completed %s' % pattern before_log = self._get_update_engine_log(r_index=1) before_match = re.findall(before_pattern, before_log)[-1] after_pattern = 'Resuming after %s' % pattern after_log = self._get_update_engine_log(r_index=0) after_match = re.findall(after_pattern, after_log)[0] logging.debug('Progress before interrupt: %s', before_match) logging.debug('Progress after interrupt: %s', after_match) # Check the Resuming progress is greater than Completed progress. for i in range(0, len(before_match)): logging.debug('Comparing %d and %d', int(before_match[i]), int(after_match[i])) if int(before_match[i]) > int(after_match[i]): return False return True def _append_query_to_url(self, url, query_dict): """ Appends the dictionary kwargs to the URL url as query string. This function will replace the already existing query strings in url with the ones in the input dictionary. I also removes keys that have a None value. @param url: The given input URL. @param query_dicl: A dictionary of key/values to be converted to query string. @return: The same input URL url but with new query string items added. """ # TODO(ahassani): This doesn't work (or maybe should not) for queries # with multiple values for a specific key. parsed_url = list(urlparse.urlsplit(url)) parsed_query = urlparse.parse_qs(parsed_url[3]) for k, v in query_dict.items(): parsed_query[k] = [v] parsed_url[3] = '&'.join( '%s=%s' % (k, v[0]) for k, v in parsed_query.items() if v[0] is not None) return urlparse.urlunsplit(parsed_url) def _check_for_update(self, update_url, interactive=True, wait_for_completion=False, check_kernel_after_update=True, **kwargs): """ Starts a background update check. @param update_url: The URL to get an update from. @param interactive: True if we are doing an interactive update. @param wait_for_completion: True for --update, False for --check_for_update. @param check_kernel_after_update: True to check kernel state after a successful update. False to skip. wait_for_completion must also be True. @param kwargs: The dictionary to be converted to a query string and appended to the end of the update URL. e.g: {'critical_update': True, 'foo': 'bar'} -> 'http:/127.0.0.1:8080/update?critical_update=True&foo=bar' Look at nebraska.py or devserver.py for the list of accepted values. If there was already query string in update_url, it will append the new values and override the old ones. """ update_url = self._append_query_to_url(update_url, kwargs) cmd = [self._UPDATE_ENGINE_CLIENT_CMD, '--update' if wait_for_completion else '--check_for_update', '--omaha_url=%s' % update_url] if not interactive: cmd.append('--interactive=false') self._run(cmd, ignore_status=False) if wait_for_completion and check_kernel_after_update: kernel_utils.verify_kernel_state_after_update( self._host if hasattr(self, '_host') else None) def _rollback(self, powerwash=False): """ Perform a rollback of rootfs. @param powerwash: True to powerwash along with the rollback. """ cmd = [self._UPDATE_ENGINE_CLIENT_CMD, '--rollback', '--follow'] if not powerwash: cmd.append('--nopowerwash') logging.info('Performing rollback with cmd: %s.', cmd) self._run(cmd) kernel_utils.verify_kernel_state_after_update(self._host) def _restart_update_engine(self, ignore_status=False): """ Restarts update-engine. @param ignore_status: True to not raise exception on command failure. """ self._run(['restart', 'update-engine'], ignore_status=ignore_status) def _save_extra_update_engine_logs(self, number_of_logs): """ Get the last X number of update_engine logs on the DUT. @param number_of_logs: The number of logs to save. """ files = self._get_update_engine_logs() for i in range(number_of_logs if number_of_logs <= len(files) else len(files)): file = os.path.join(self._UPDATE_ENGINE_LOG_DIR, files[i]) self._get_file(file, self.resultsdir) def _get_update_engine_logs(self, timeout=3600, ignore_timeout=True): """ Helper function to return the list of files in /var/log/update_engine/. @param timeout: How many seconds to wait for command to complete. @param ignore_timeout: True if we should not throw an error on timeout. """ cmd = ['ls', '-t', '-1', self._UPDATE_ENGINE_LOG_DIR] return self._run(cmd, timeout=timeout, ignore_timeout=ignore_timeout).stdout.splitlines() def _get_update_engine_log(self, r_index=0, timeout=3600, ignore_timeout=True): """ Returns the last r_index'th update_engine log. @param r_index: The index of the last r_index'th update_engine log in order they were created. For example: 0 -> last one. 1 -> second to last one. @param timeout: How many seconds to wait for command to complete. @param ignore_timeout: True if we should not throw an error on timeout. """ files = self._get_update_engine_logs() log_file = os.path.join(self._UPDATE_ENGINE_LOG_DIR, files[r_index]) return self._run(['cat', log_file]).stdout def _create_custom_lsb_release(self, update_url, build='0.0.0.0', **kwargs): """ Create a custom lsb-release file. In order to tell OOBE to ping a different update server than the default we need to create our own lsb-release. We will include a deserver update URL. @param update_url: String of url to use for update check. @param build: String of the build number to use. Represents the Chrome OS build this device thinks it is on. @param kwargs: A dictionary of key/values to be made into a query string and appended to the update_url """ update_url = self._append_query_to_url(update_url, kwargs) self._run(['mkdir', os.path.dirname(self._CUSTOM_LSB_RELEASE)], ignore_status=True) self._run(['touch', self._CUSTOM_LSB_RELEASE]) self._run(['echo', 'CHROMEOS_RELEASE_VERSION=%s' % build, '>', self._CUSTOM_LSB_RELEASE]) self._run(['echo', 'CHROMEOS_AUSERVER=%s' % update_url, '>>', self._CUSTOM_LSB_RELEASE]) def _clear_custom_lsb_release(self): """ Delete the custom release file, if any. Intended to clear work done by _create_custom_lsb_release(). """ self._run(['rm', self._CUSTOM_LSB_RELEASE], ignore_status=True) def _remove_update_engine_pref(self, pref): """ Delete an update_engine pref file. @param pref: The pref file to delete """ pref_file = os.path.join(self._UPDATE_ENGINE_PREFS_DIR, pref) self._run(['rm', pref_file], ignore_status=True) def _get_update_requests(self): """ Get the contents of all the update requests from the most recent log. @returns: a sequential list of xml blocks or None if none. """ update_log = self._get_update_engine_log() # Matches . The match can be on multiple # lines and the search is not greedy so it only matches one block. return re.findall(r'', update_log, re.DOTALL) def _get_time_of_last_update_request(self): """ Get the time of the last update request from most recent logfile. @returns: seconds since epoch of when last update request happened (second accuracy), or None if no such timestamp exists. """ update_log = self._get_update_engine_log() # Matches any single line with "MMDD/HHMMSS ... Request ... xml", e.g. # "[0723/133526:INFO:omaha_request_action.cc(794)] Request: the reply with the update -> the report that install has started -> report that install has finished, etc. This function finds the first request in the latest such chain. This message has no eventtype listed, or is rebooted_after_update type (as an artifact from a previous update since this one). Subsequent messages in the chain have different eventtype values. @returns: string of the entire update request or None. """ requests = self._get_update_requests() if not requests: return None MATCH_STR = r'eventtype="(.*?)"' for i in xrange(len(requests) - 1, -1, -1): search = re.search(MATCH_STR, requests[i]) if (not search or (search.group(1) == str(update_engine_event.EVENT_TYPE_REBOOTED_AFTER_UPDATE))): return requests[i] return None