1# Copyright 2017 The Chromium OS Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5import json 6import logging 7import os 8import re 9import update_engine_event as uee 10import urlparse 11 12from autotest_lib.client.common_lib import error 13from autotest_lib.client.common_lib import utils 14from autotest_lib.client.common_lib.cros import dev_server 15from autotest_lib.server import test 16from autotest_lib.server.cros.dynamic_suite import tools 17from autotest_lib.server.cros.update_engine import omaha_devserver 18from datetime import datetime, timedelta 19from update_engine_event import UpdateEngineEvent 20 21class UpdateEngineTest(test.test): 22 """Class for comparing expected update_engine events against actual ones. 23 24 During a rootfs update, there are several events that are fired (e.g. 25 download_started, download_finished, update_started etc). Each event has 26 properties associated with it that need to be verified. 27 28 In this class we build a list of expected events (list of 29 UpdateEngineEvent objects), and compare that against a "hostlog" returned 30 from update_engine from the update. This hostlog is a json list of 31 events fired during the update. It is accessed by the api/hostlog URL on the 32 devserver during the update. 33 34 We can also verify the hostlog of a one-time update event that is fired 35 after rebooting after an update. 36 37 During a typical autoupdate we will check both of these hostlogs. 38 """ 39 version = 1 40 41 # Timeout periods, given in seconds. 42 _INITIAL_CHECK_TIMEOUT = 12 * 60 43 _DOWNLOAD_STARTED_TIMEOUT = 4 * 60 44 # See https://crbug.com/731214 before changing _DOWNLOAD_FINISHED_TIMEOUT 45 _DOWNLOAD_FINISHED_TIMEOUT = 20 * 60 46 _UPDATE_COMPLETED_TIMEOUT = 4 * 60 47 _POST_REBOOT_TIMEOUT = 15 * 60 48 49 # The names of the two hostlog files we will be verifying 50 _DEVSERVER_HOSTLOG_ROOTFS = 'devserver_hostlog_rootfs' 51 _DEVSERVER_HOSTLOG_REBOOT = 'devserver_hostlog_reboot' 52 53 _CELLULAR_BUCKET = 'gs://chromeos-throw-away-bucket/CrOSPayloads/Cellular/' 54 55 56 def initialize(self): 57 self._hostlog_filename = None 58 self._hostlog_events = [] 59 self._num_consumed_events = 0 60 self._current_timestamp = None 61 self._expected_events = [] 62 self._omaha_devserver = None 63 self._host = None 64 # Some AU tests use multiple DUTs 65 self._hosts = None 66 67 def cleanup(self): 68 if self._omaha_devserver is not None: 69 self._omaha_devserver.stop_devserver() 70 71 72 def _get_expected_events_for_rootfs_update(self, source_release): 73 """Creates a list of expected events fired during a rootfs update. 74 75 There are 4 events fired during a rootfs update. We will create these 76 in the correct order with the correct data, timeout, and error 77 condition function. 78 """ 79 initial_check = UpdateEngineEvent( 80 version=source_release, 81 on_error=self._error_initial_check) 82 download_started = UpdateEngineEvent( 83 event_type=uee.EVENT_TYPE_DOWNLOAD_STARTED, 84 event_result=uee.EVENT_RESULT_SUCCESS, 85 version=source_release, 86 on_error=self._error_incorrect_event) 87 download_finished = UpdateEngineEvent( 88 event_type=uee.EVENT_TYPE_DOWNLOAD_FINISHED, 89 event_result=uee.EVENT_RESULT_SUCCESS, 90 version=source_release, 91 on_error=self._error_incorrect_event) 92 update_complete = UpdateEngineEvent( 93 event_type=uee.EVENT_TYPE_UPDATE_COMPLETE, 94 event_result=uee.EVENT_RESULT_SUCCESS, 95 version=source_release, 96 on_error=self._error_incorrect_event) 97 98 # There is an error message if any of them take too long to fire. 99 initial_error = self._timeout_error_message('an initial update check', 100 self._INITIAL_CHECK_TIMEOUT) 101 dls_error = self._timeout_error_message('a download started ' 102 'notification', 103 self._DOWNLOAD_STARTED_TIMEOUT, 104 uee.EVENT_TYPE_DOWNLOAD_STARTED) 105 dlf_error = self._timeout_error_message('a download finished ' 106 'notification', 107 self._DOWNLOAD_FINISHED_TIMEOUT, 108 uee.EVENT_TYPE_DOWNLOAD_FINISHED 109 ) 110 uc_error = self._timeout_error_message('an update complete ' 111 'notification', 112 self._UPDATE_COMPLETED_TIMEOUT, 113 uee.EVENT_TYPE_UPDATE_COMPLETE) 114 115 # Build an array of tuples (event, timeout, timeout_error_message) 116 self._expected_events = [ 117 (initial_check, self._INITIAL_CHECK_TIMEOUT, initial_error), 118 (download_started, self._DOWNLOAD_STARTED_TIMEOUT, dls_error), 119 (download_finished, self._DOWNLOAD_FINISHED_TIMEOUT, dlf_error), 120 (update_complete, self._UPDATE_COMPLETED_TIMEOUT, uc_error) 121 ] 122 123 124 def _get_expected_event_for_post_reboot_check(self, source_release, 125 target_release): 126 """Creates the expected event fired during post-reboot update check.""" 127 post_reboot_check = UpdateEngineEvent( 128 event_type=uee.EVENT_TYPE_REBOOTED_AFTER_UPDATE, 129 event_result=uee.EVENT_RESULT_SUCCESS, 130 version=target_release, 131 previous_version=source_release, 132 on_error=self._error_reboot_after_update) 133 err = self._timeout_error_message('a successful reboot ' 134 'notification', 135 self._POST_REBOOT_TIMEOUT, 136 uee.EVENT_TYPE_REBOOTED_AFTER_UPDATE) 137 138 self._expected_events = [ 139 (post_reboot_check, self._POST_REBOOT_TIMEOUT, err) 140 ] 141 142 143 def _read_hostlog_events(self): 144 """Read the list of events from the hostlog json file.""" 145 if len(self._hostlog_events) <= self._num_consumed_events: 146 try: 147 with open(self._hostlog_filename, 'r') as out_log: 148 self._hostlog_events = json.loads(out_log.read()) 149 except Exception as e: 150 raise error.TestFail('Error while reading the hostlogs ' 151 'from devserver: %s' % e) 152 153 154 def _get_next_hostlog_event(self): 155 """Returns the next event from the hostlog json file. 156 157 @return The next new event in the host log 158 None if no such event was found or an error occurred. 159 """ 160 self._read_hostlog_events() 161 # Return next new event, if one is found. 162 if len(self._hostlog_events) > self._num_consumed_events: 163 new_event = { 164 key: str(val) for key, val 165 in self._hostlog_events[self._num_consumed_events].iteritems() 166 } 167 self._num_consumed_events += 1 168 logging.info('Consumed new event: %s', new_event) 169 return new_event 170 171 172 def _verify_event_with_timeout(self, expected_event, timeout, on_timeout): 173 """Verify an expected event occurs within a given timeout. 174 175 @param expected_event: an expected event 176 @param timeout: specified in seconds 177 @param on_timeout: A string to return if timeout occurs, or None. 178 179 @return None if event complies, an error string otherwise. 180 """ 181 actual_event = self._get_next_hostlog_event() 182 if actual_event: 183 # If this is the first event, set it as the current time 184 if self._current_timestamp is None: 185 self._current_timestamp = datetime.strptime(actual_event[ 186 'timestamp'], 187 '%Y-%m-%d %H:%M:%S') 188 189 # Get the time stamp for the current event and convert to datetime 190 timestamp = actual_event['timestamp'] 191 event_timestamp = datetime.strptime(timestamp, '%Y-%m-%d %H:%M:%S') 192 193 # Add the timeout onto the timestamp to get its expiry 194 event_timeout = self._current_timestamp + timedelta(seconds=timeout) 195 196 # If the event happened before the timeout 197 if event_timestamp < event_timeout: 198 difference = event_timestamp - self._current_timestamp 199 logging.info('Event took %s seconds to fire during the ' 200 'update', difference.seconds) 201 result = expected_event.equals(actual_event) 202 self._current_timestamp = event_timestamp 203 return result 204 205 logging.error('The expected event was not found in the hostlog: %s', 206 expected_event) 207 return on_timeout 208 209 210 def _error_initial_check(self, expected, actual, mismatched_attrs): 211 """Error message for when update fails at initial update check.""" 212 err_msg = ('The update test appears to have completed successfully but ' 213 'we found a problem while verifying the hostlog of events ' 214 'returned from the update. Some attributes reported for ' 215 'the initial update check event are not what we expected: ' 216 '%s. ' % mismatched_attrs) 217 if 'version' in mismatched_attrs: 218 err_msg += ('The expected version is (%s) but reported version was ' 219 '(%s). ' % (expected['version'], actual['version'])) 220 err_msg += ('If reported version = target version, it is likely ' 221 'we retried the update because the test thought the ' 222 'first attempt failed but it actually succeeded ' 223 '(e.g due to SSH disconnect, DUT not reachable by ' 224 'hostname, applying stateful failed after rootfs ' 225 'succeeded). This second update attempt is then started' 226 ' from the target version instead of the source ' 227 'version, so our hostlog verification is invalid.') 228 err_msg += ('Check the full hostlog for this update in the %s file in ' 229 'the %s directory.' % (self._DEVSERVER_HOSTLOG_ROOTFS, 230 dev_server.AUTO_UPDATE_LOG_DIR)) 231 return err_msg 232 233 234 def _error_incorrect_event(self, expected, actual, mismatched_attrs): 235 """Error message for when an event is not what we expect.""" 236 return ('The update appears to have completed successfully but ' 237 'when analysing the update events in the hostlog we have ' 238 'found that one of the events is incorrect. This should ' 239 'never happen. The mismatched attributes are: %s. We expected ' 240 '%s, but got %s.' % (mismatched_attrs, expected, actual)) 241 242 243 def _error_reboot_after_update(self, expected, actual, mismatched_attrs): 244 """Error message for problems in the post-reboot update check.""" 245 err_msg = ('The update completed successfully but there was a problem ' 246 'with the post-reboot update check. After a successful ' 247 'update, we do a final update check to parse a unique ' 248 'omaha request. The mistmatched attributes for this update ' 249 'check were %s. ' % mismatched_attrs) 250 if 'event_result' in mismatched_attrs: 251 err_msg += ('The event_result was expected to be (%s:%s) but ' 252 'reported (%s:%s). ' % 253 (expected['event_result'], 254 uee.get_event_result(expected['event_result']), 255 actual.get('event_result'), 256 uee.get_event_result(actual.get('event_result')))) 257 if 'event_type' in mismatched_attrs: 258 err_msg += ('The event_type was expeted to be (%s:%s) but ' 259 'reported (%s:%s). ' % 260 (expected['event_type'], 261 uee.get_event_type(expected['event_type']), 262 actual.get('event_type'), 263 uee.get_event_type(actual.get('event_type')))) 264 if 'version' in mismatched_attrs: 265 err_msg += ('The version was expected to be (%s) but ' 266 'reported (%s). This probably means that the payload ' 267 'we applied was incorrect or corrupt. ' % 268 (expected['version'], actual['version'])) 269 if 'previous_version' in mismatched_attrs: 270 err_msg += ('The previous version is expected to be (%s) but ' 271 'reported (%s). This can happen if we retried the ' 272 'update after rootfs update completed on the first ' 273 'attempt then we failed. Or if stateful got wiped and ' 274 '/var/lib/update_engine/prefs/previous-version was ' 275 'deleted. ' % (expected['previous_version'], 276 actual['previous_version'])) 277 err_msg += ('You can see the full hostlog for this update check in ' 278 'the %s file within the %s directory. ' % 279 (self._DEVSERVER_HOSTLOG_REBOOT, 280 dev_server.AUTO_UPDATE_LOG_DIR)) 281 return err_msg 282 283 284 def _timeout_error_message(self, desc, timeout, event_type=None): 285 """Error message for when an event takes too long to fire.""" 286 if event_type is not None: 287 desc += ' (%s)' % uee.get_event_type(event_type) 288 return ('The update completed successfully but one of the steps of ' 289 'the update took longer than we would like. We failed to ' 290 'receive %s within %d seconds.' % (desc, timeout)) 291 292 293 def _stage_payload_by_uri(self, payload_uri): 294 """Stage a payload based on its GS URI. 295 296 This infers the build's label, filename and GS archive from the 297 provided GS URI. 298 299 @param payload_uri: The full GS URI of the payload. 300 301 @return URL of the staged payload on the server. 302 303 @raise error.TestError if there's a problem with staging. 304 305 """ 306 archive_url, _, filename = payload_uri.rpartition('/') 307 build_name = urlparse.urlsplit(archive_url).path.strip('/') 308 return self._stage_payload(build_name, filename, 309 archive_url=archive_url) 310 311 312 def _stage_payload(self, build_name, filename, archive_url=None): 313 """Stage the given payload onto the devserver. 314 315 Works for either a stateful or full/delta test payload. Expects the 316 gs_path or a combo of build_name + filename. 317 318 @param build_name: The build name e.g. x86-mario-release/<version>. 319 If set, assumes default gs archive bucket and 320 requires filename to be specified. 321 @param filename: In conjunction with build_name, this is the file you 322 are downloading. 323 @param archive_url: An optional GS archive location, if not using the 324 devserver's default. 325 326 @return URL of the staged payload on the server. 327 328 @raise error.TestError if there's a problem with staging. 329 330 """ 331 try: 332 self._autotest_devserver.stage_artifacts(image=build_name, 333 files=[filename], 334 archive_url=archive_url) 335 return self._autotest_devserver.get_staged_file_url(filename, 336 build_name) 337 except dev_server.DevServerException, e: 338 raise error.TestError('Failed to stage payload: %s' % e) 339 340 341 def _get_payload_url(self, build=None, full_payload=True): 342 """ 343 Gets the GStorage URL of the full or delta payload for this build. 344 345 @param build: build string e.g samus-release/R65-10225.0.0. 346 @param full_payload: True for full payload. False for delta. 347 348 @returns the payload URL. 349 350 """ 351 if build is None: 352 if self._job_repo_url is None: 353 self._job_repo_url = self._get_job_repo_url() 354 _, build = tools.get_devserver_build_from_package_url( 355 self._job_repo_url) 356 357 gs = dev_server._get_image_storage_server() 358 if full_payload: 359 # Example: chromeos_R65-10225.0.0_samus_full_dev.bin 360 regex = 'chromeos_%s*_full_*' % build.rpartition('/')[2] 361 else: 362 # Example: chromeos_R65-10225.0.0_R65-10225.0.0_samus_delta_dev.bin 363 regex = 'chromeos_%s*_delta_*' % build.rpartition('/')[2] 364 payload_url_regex = gs + build + '/' + regex 365 logging.debug('Trying to find payloads at %s', payload_url_regex) 366 payloads = utils.gs_ls(payload_url_regex) 367 if not payloads: 368 raise error.TestFail('Could not find payload for %s', build) 369 logging.debug('Payloads found: %s', payloads) 370 return payloads[0] 371 372 373 def _get_staged_file_info(self, staged_url): 374 """ 375 Gets the staged files info that includes SHA256 and size. 376 377 @param staged_url: the staged file url. 378 379 @returns file info (SHA256 and size). 380 381 """ 382 split_url = staged_url.rpartition('/static/') 383 file_info_url = os.path.join(split_url[0], 'api/fileinfo', split_url[2]) 384 logging.info('file info url: %s', file_info_url) 385 devserver_hostname = urlparse.urlparse(file_info_url).hostname 386 cmd = 'ssh %s \'curl "%s"\'' % (devserver_hostname, 387 utils.sh_escape(file_info_url)) 388 try: 389 result = utils.run(cmd).stdout 390 return json.loads(result) 391 except error.CmdError as e: 392 logging.error('Failed to read file info: %s', e) 393 raise error.TestFail('Could not reach fileinfo API on devserver.') 394 395 396 def _get_job_repo_url(self): 397 """Gets the job_repo_url argument supplied to the test by the lab.""" 398 if self._hosts is not None: 399 self._host = self._hosts[0] 400 if self._host is None: 401 raise error.TestFail('No host specified by AU test.') 402 info = self._host.host_info_store.get() 403 return info.attributes.get(self._host.job_repo_url_attribute, '') 404 405 406 def _check_for_cellular_entries_in_update_log(self, update_engine_log): 407 """ 408 Check update_engine.log for log entries about cellular. 409 410 @param update_engine_log: The text of an update_engine.log file. 411 412 """ 413 logging.info('Making sure we have cellular entries in update_engine ' 414 'log.') 415 line1 = "Allowing updates over cellular as permission preference is " \ 416 "set to true." 417 line2 = "We are connected via cellular, Updates allowed: Yes" 418 for line in [line1, line2]: 419 ue = re.compile(line) 420 if ue.search(update_engine_log) is None: 421 raise error.TestFail('We did not find cellular string "%s" in ' 422 'the update_engine log. Please check the ' 423 'update_engine logs in the results ' 424 'directory.' % line) 425 426 427 def _copy_payload_to_public_bucket(self, payload_url): 428 """ 429 Copy payload and make link public. 430 431 @param payload_url: Payload URL on Google Storage. 432 433 @returns The payload URL that is now publicly accessible. 434 435 """ 436 payload_filename = payload_url.rpartition('/')[2] 437 utils.run('gsutil cp %s %s' % (payload_url, self._CELLULAR_BUCKET)) 438 new_gs_url = self._CELLULAR_BUCKET + payload_filename 439 utils.run('gsutil acl ch -u AllUsers:R %s' % new_gs_url) 440 return new_gs_url.replace('gs://', 'https://storage.googleapis.com/') 441 442 443 def verify_update_events(self, source_release, hostlog_filename, 444 target_release=None): 445 """Compares a hostlog file against a set of expected events. 446 447 This is the main function of this class. It takes in an expected 448 source and target version along with a hostlog file location. It will 449 then generate the expected events based on the data and compare it 450 against the events listed in the hostlog json file. 451 """ 452 self._hostlog_events = [] 453 self._num_consumed_events = 0 454 self._current_timestamp = None 455 if target_release is not None: 456 self._get_expected_event_for_post_reboot_check(source_release, 457 target_release) 458 else: 459 self._get_expected_events_for_rootfs_update(source_release) 460 461 self._hostlog_filename = hostlog_filename 462 logging.info('Checking update steps with hostlog file: %s', 463 self._hostlog_filename) 464 465 for expected_event, timeout, on_timeout in self._expected_events: 466 logging.info('Expecting %s within %s seconds', expected_event, 467 timeout) 468 err_msg = self._verify_event_with_timeout( 469 expected_event, timeout, on_timeout) 470 if err_msg is not None: 471 logging.error('Failed expected event: %s', err_msg) 472 raise UpdateEngineEventMissing(err_msg) 473 474 475 def get_update_url_for_test(self, job_repo_url, full_payload=True, 476 critical_update=False, max_updates=1, 477 cellular=False): 478 """ 479 Get the correct update URL for autoupdate tests to use. 480 481 There are bunch of different update configurations that are required 482 by AU tests. Some tests need a full payload, some need a delta payload. 483 Some require the omaha response to be critical or be able to handle 484 multiple DUTs etc. This function returns the correct update URL to the 485 test based on the inputs parameters. 486 487 Ideally all updates would use an existing lab devserver to handle the 488 updates. However the lab devservers default setup does not work for 489 all test needs. So we also kick off our own omaha_devserver for the 490 test run some times. 491 492 This tests expects the test to set self._host or self._hosts. 493 494 @param job_repo_url: string url containing the current build. 495 @param full_payload: bool whether we want a full payload. 496 @param critical_update: bool whether we need a critical update. 497 @param max_updates: int number of updates the test will perform. This 498 is passed to src/platform/dev/devserver.py if we 499 create our own deverver. 500 @param cellular: update will be done over cellular connection. 501 502 @returns an update url string. 503 504 """ 505 if job_repo_url is None: 506 self._job_repo_url = self._get_job_repo_url() 507 else: 508 self._job_repo_url = job_repo_url 509 if not self._job_repo_url: 510 raise error.TestFail('There was no job_repo_url so we cannot get ' 511 'a payload to use.') 512 ds_url, build = tools.get_devserver_build_from_package_url( 513 self._job_repo_url) 514 515 # We always stage the payloads on the existing lab devservers. 516 self._autotest_devserver = dev_server.ImageServer(ds_url) 517 518 if cellular: 519 # Get the google storage url of the payload. We will be copying 520 # the payload to a public google storage bucket (similar location 521 # to updates via autest command). 522 payload_url = self._get_payload_url(build, 523 full_payload=full_payload) 524 return self._copy_payload_to_public_bucket(payload_url) 525 526 if full_payload: 527 self._autotest_devserver.stage_artifacts(build, ['full_payload']) 528 if not critical_update: 529 # We can use the same lab devserver to handle the update. 530 return self._autotest_devserver.get_update_url(build) 531 else: 532 staged_url = self._autotest_devserver._get_image_url(build) 533 else: 534 # We need to stage delta ourselves due to crbug.com/793434. 535 delta_payload = self._get_payload_url(build, full_payload=False) 536 staged_url = self._stage_payload_by_uri(delta_payload) 537 538 # We need to start our own devserver for the rest of the cases. 539 self._omaha_devserver = omaha_devserver.OmahaDevserver( 540 self._autotest_devserver.hostname, staged_url, 541 max_updates=max_updates, critical_update=critical_update) 542 self._omaha_devserver.start_devserver() 543 return self._omaha_devserver.get_update_url() 544 545 546class UpdateEngineEventMissing(error.TestFail): 547 """Raised if the hostlog is missing an expected event.""" 548