full_release_test.py revision 52113d9f28ffa033ef5574bc0a4742a977ac3ba9
1#!/usr/bin/python 2# 3# Copyright (c) 2012 The Chromium OS Authors. All rights reserved. 4# Use of this source code is governed by a BSD-style license that can be 5# found in the LICENSE file. 6 7"""Infer and spawn a complete set of Chrome OS release autoupdate tests. 8 9By default, this runs against the AFE configured in the global_config.ini-> 10SERVER->hostname. You can run this on a local AFE by modifying this value in 11your shadow_config.ini to localhost. 12""" 13 14import logging 15import optparse 16import os 17import re 18import subprocess 19import sys 20 21import common 22from autotest_lib.client.common_lib import priorities 23from autotest_lib.server import frontend 24from autotest_lib.utils import external_packages 25 26from autotest_lib.site_utils.autoupdate import import_common 27from autotest_lib.site_utils.autoupdate import release as release_util 28from autotest_lib.site_utils.autoupdate import test_image 29from autotest_lib.site_utils.autoupdate.lib import test_control 30from autotest_lib.site_utils.autoupdate.lib import test_params 31 32chromite = import_common.download_and_import('chromite', 33 external_packages.ChromiteRepo()) 34try: 35 from chromite.cbuildbot import cbuildbot_config 36except ImportError: 37 from chromite.buildbot import cbuildbot_config 38 39# Autotest pylint is more restrictive than it should with args. 40#pylint: disable=C0111 41 42# Global reference objects. 43_release_info = release_util.ReleaseInfo() 44 45_log_debug = 'debug' 46_log_normal = 'normal' 47_log_verbose = 'verbose' 48_valid_log_levels = _log_debug, _log_normal, _log_verbose 49_autotest_url_format = r'http://%(host)s/afe/#tab_id=view_job&object_id=%(job)s' 50_default_dump_dir = os.path.realpath( 51 os.path.join(os.path.dirname(__file__), '..', '..', 'server', 52 'site_tests', test_control.get_test_name())) 53_build_version = '%(branch)s-%(release)s' 54 55 56class FullReleaseTestError(BaseException): 57 pass 58 59 60def get_release_branch(release): 61 """Returns the release branch for the given release. 62 63 @param release: release version e.g. 3920.0.0. 64 65 @returns the branch string e.g. R26. 66 """ 67 return _release_info.get_branch(release) 68 69 70class TestConfigGenerator(object): 71 """Class for generating test configs.""" 72 73 def __init__(self, board, tested_release, test_nmo, test_npo, 74 src_as_payload, use_mp_images, archive_url=None): 75 """ 76 @param board: the board under test 77 @param tested_release: the tested release version 78 @param test_nmo: whether we should infer N-1 tests 79 @param test_npo: whether we should infer N+1 tests 80 @param src_as_payload: if True, use the full payload as the src image as 81 opposed to using the test image (the latter requires servo). 82 @param use_mp_images: use mp images/payloads. 83 @param archive_url: optional gs url to find payloads. 84 85 """ 86 self.board = board 87 self.tested_release = tested_release 88 self.test_nmo = test_nmo 89 self.test_npo = test_npo 90 self.src_as_payload = src_as_payload 91 self.use_mp_images = use_mp_images 92 if archive_url: 93 self.archive_url = archive_url 94 else: 95 branch = get_release_branch(tested_release) 96 build_version = _build_version % dict(branch=branch, 97 release=tested_release) 98 self.archive_url = test_image.get_default_archive_url( 99 board, build_version) 100 101 # Get the prefix which is an archive_url stripped of its trailing 102 # version. We rstrip in the case of any trailing /'s. 103 # Use archive prefix for any nmo / specific builds. 104 self.archive_prefix = self.archive_url.rstrip('/').rpartition('/')[0] 105 106 107 def _get_source_uri_from_build_version(self, build_version): 108 """Returns the source_url given build version. 109 110 Args: 111 build_version: the full build version i.e. R27-3823.0.0-a2. 112 """ 113 # If we're looking for our own image, use the target archive_url if set 114 if self.tested_release in build_version: 115 archive_url = self.archive_url 116 else: 117 archive_url = test_image.get_archive_url_from_prefix( 118 self.archive_prefix, build_version) 119 120 if self.src_as_payload: 121 return test_image.find_payload_uri(archive_url, single=True) 122 else: 123 return test_image.find_image_uri(archive_url) 124 125 126 def _get_source_uri_from_release(self, release): 127 """Returns the source uri for a given release or None if not found. 128 129 Args: 130 release: required release number. 131 """ 132 branch = get_release_branch(release) 133 return self._get_source_uri_from_build_version( 134 _build_version % dict(branch=branch, release=release)) 135 136 137 def generate_mp_image_npo_nmo_list(self): 138 """Generates N+1/N-1 test configurations with MP-signed images. 139 140 Computes a list of N+1 (npo) and/or N-1 (nmo) test configurations for a 141 given tested release and board. 142 143 @return A pair of TestConfig objects corresponding to the N+1 and N-1 144 tests. 145 146 @raise FullReleaseTestError if something went wrong 147 148 """ 149 # TODO(garnold) generate N+/-1 configurations for MP-signed images. 150 raise NotImplementedError( 151 'generation of mp-signed test configs not implemented') 152 153 154 def generate_mp_image_specific_list(self, specific_source_releases): 155 """Generates specific test configurations with MP-signed images. 156 157 Returns a list of test configurations from a given list of source 158 releases to the given tested release and board. 159 160 @param specific_source_releases: list of source releases to test 161 162 @return List of TestConfig objects corresponding to the given source 163 releases. 164 165 """ 166 # TODO(garnold) generate specific configurations for MP-signed images. 167 raise NotImplementedError( 168 'generation of mp-signed test configs not implemented') 169 170 171 def generate_test_image_config(self, name, is_delta_update, source_release, 172 payload_uri, source_uri): 173 """Constructs a single test config with given arguments. 174 175 It'll automatically find and populate source/target branches as well as 176 the source image URI. 177 178 @param name: a descriptive name for the test 179 @param is_delta_update: whether we're testing a delta update 180 @param source_release: the version of the source image (before update) 181 @param target_release: the version of the target image (after update) 182 @param payload_uri: URI of the update payload. 183 @param source_uri: URI of the source image/payload. 184 185 """ 186 # Extracts just the main version from a version that may contain 187 # attempts or a release candidate suffix i.e. 3928.0.0-a2 -> 188 # base_version=3928.0.0. 189 _version_re = re.compile( 190 '(?P<base_version>[0-9.]+)(?:\-[a-z]+[0-9]+])*') 191 192 # Pass only the base versions without any build specific suffixes. 193 source_version = _version_re.match(source_release).group('base_version') 194 target_version = _version_re.match(self.tested_release).group( 195 'base_version') 196 return test_params.TestConfig( 197 self.board, name, self.use_mp_images, is_delta_update, 198 source_version, target_version, source_uri, payload_uri) 199 200 201 @staticmethod 202 def _parse_build_version(build_version): 203 """Returns a branch, release tuple from a full build_version. 204 205 Args: 206 build_version: build version to parse e.g. 'R27-3905.0.0' 207 """ 208 version = r'[0-9a-z.\-]+' 209 # The date portion only appears in non-release builds. 210 date = r'([0-9]+_[0-9]+_[0-9]+_[0-9]+)*' 211 # Returns groups for branches and release numbers from build version. 212 _build_version_re = re.compile( 213 '(?P<branch>R[0-9]+)-(?P<release>' + version + date + version + ')') 214 215 match = _build_version_re.match(build_version) 216 if not match: 217 logging.warning('version %s did not match version format', 218 build_version) 219 return None 220 221 return match.group('branch'), match.group('release') 222 223 224 @staticmethod 225 def _parse_delta_filename(filename): 226 """Parses a delta payload name into its source/target versions. 227 228 Args: 229 filename: Delta filename to parse e.g. 230 'chromeos_R27-3905.0.0_R27-3905.0.0_stumpy_delta_dev.bin' 231 232 Returns: tuple with source_version, and target_version. 233 """ 234 version = r'[0-9a-z.\-]+' 235 # The date portion only appears in non-release builds. 236 date = r'([0-9]+_[0-9]+_[0-9]+_[0-9]+)*' 237 # Matches delta format name and returns groups for source and target 238 # versions. 239 _delta_re = re.compile( 240 'chromeos_' 241 '(?P<s_version>R[0-9]+-' + version + date + version + ')' 242 '_' 243 '(?P<t_version>R[0-9]+-' + version + date + version + ')' 244 '_[\w.]+') 245 match = _delta_re.match(filename) 246 if not match: 247 logging.warning('filename %s did not match delta format', filename) 248 return None 249 250 return match.group('s_version'), match.group('t_version') 251 252 253 def generate_test_image_npo_nmo_list(self): 254 """Generates N+1/N-1 test configurations with test images. 255 256 Computes a list of N+1 (npo) and/or N-1 (nmo) test configurations for a 257 given tested release and board. This is done by scanning of the test 258 image repository, looking for update payloads; normally, we expect to 259 find at most one update payload of each of the aforementioned types. 260 261 @return A list of TestConfig objects corresponding to the N+1 and N-1 262 tests. 263 264 @raise FullReleaseTestError if something went wrong 265 266 """ 267 if not (self.test_nmo or self.test_npo): 268 return [] 269 270 # Find all test delta payloads involving the release version at hand, 271 # then figure out which is which. 272 found = set() 273 test_list = [] 274 payload_uri_list = test_image.find_payload_uri( 275 self.archive_url, delta=True) 276 for payload_uri in payload_uri_list: 277 # Infer the source and target release versions. These versions will 278 # be something like 'R43-6831.0.0' for release builds and 279 # 'R43-6831.0.0-a1' for trybots. 280 file_name = os.path.basename(payload_uri) 281 source_version, target_version = ( 282 self._parse_delta_filename(file_name)) 283 _, source_release = self._parse_build_version(source_version) 284 285 # The target version should contain the tested release otherwise 286 # this is a delta payload to a different version. They are not equal 287 # since the tested_release doesn't include the milestone, for 288 # example, 940.0.1 release in the R28-940.0.1-a1 version. 289 if self.tested_release not in target_version: 290 raise FullReleaseTestError( 291 'delta target release %s does not contain %s (%s)', 292 target_version, self.tested_release, self.board) 293 294 # Search for the full payload to the source_version in the 295 # self.archive_url directory if the source_version is the tested 296 # release (such as in a npo test), or in the standard location if 297 # the source is some other build. Note that this function handles 298 # both cases. 299 source_uri = self._get_source_uri_from_build_version(source_version) 300 301 if not source_uri: 302 logging.warning('cannot find source for %s, %s', self.board, 303 source_version) 304 continue 305 306 # Determine delta type, make sure it was not already discovered. 307 delta_type = 'npo' if source_version == target_version else 'nmo' 308 # Only add test configs we were asked to test. 309 if (delta_type == 'npo' and not self.test_npo) or ( 310 delta_type == 'nmo' and not self.test_nmo): 311 continue 312 313 if delta_type in found: 314 raise FullReleaseTestError( 315 'more than one %s deltas found (%s, %s)' % ( 316 delta_type, self.board, self.tested_release)) 317 318 found.add(delta_type) 319 320 # Generate test configuration. 321 test_list.append(self.generate_test_image_config( 322 delta_type, True, source_release, payload_uri, source_uri)) 323 324 return test_list 325 326 327 def generate_test_image_full_update_list(self, source_releases, name): 328 """Generates test configurations of full updates with test images. 329 330 Returns a list of test configurations from a given list of source 331 releases to the given tested release and board. 332 333 @param sources_releases: list of source release versions 334 @param name: name for generated test configurations 335 336 @return List of TestConfig objects corresponding to the source/target 337 pairs for the given board. 338 339 """ 340 # If there are no source releases, there's nothing to do. 341 if not source_releases: 342 logging.warning("no '%s' source release provided for %s, %s; no " 343 "tests generated", 344 name, self.board, self.tested_release) 345 return [] 346 347 # Find the full payload for the target release. 348 tested_payload_uri = test_image.find_payload_uri( 349 self.archive_url, single=True) 350 if not tested_payload_uri: 351 logging.warning("cannot find full payload for %s, %s; no '%s' tests" 352 " generated", self.board, self.tested_release, name) 353 return [] 354 355 # Construct test list. 356 test_list = [] 357 for source_release in source_releases: 358 source_uri = self._get_source_uri_from_release(source_release) 359 if not source_uri: 360 logging.warning('cannot find source for %s, %s', self.board, 361 source_release) 362 continue 363 364 test_list.append(self.generate_test_image_config( 365 name, False, source_release, tested_payload_uri, 366 source_uri)) 367 368 return test_list 369 370 371 def generate_test_image_specific_list(self, specific_source_releases): 372 """Generates specific test configurations with test images. 373 374 Returns a list of test configurations from a given list of source 375 releases to the given tested release and board. 376 377 @param specific_source_releases: list of source releases to test 378 379 @return List of TestConfig objects corresponding to the given source 380 releases. 381 382 """ 383 return self.generate_test_image_full_update_list( 384 specific_source_releases, 'specific') 385 386 387 def generate_npo_nmo_list(self): 388 """Generates N+1/N-1 test configurations. 389 390 Computes a list of N+1 (npo) and/or N-1 (nmo) test configurations for a 391 given tested release and board. 392 393 @return List of TestConfig objects corresponding to the requested test 394 types. 395 396 @raise FullReleaseTestError if something went wrong 397 398 """ 399 # Return N+1/N-1 test configurations. 400 if self.use_mp_images: 401 return self.generate_mp_image_npo_nmo_list() 402 else: 403 return self.generate_test_image_npo_nmo_list() 404 405 406 def generate_specific_list(self, specific_source_releases, generated_tests): 407 """Generates test configurations for a list of specific source releases. 408 409 Returns a list of test configurations from a given list of releases to 410 the given tested release and board. Cares to exclude test configurations 411 that were already generated elsewhere (e.g. N-1/N+1). 412 413 @param specific_source_releases: list of source release to test 414 @param generated_tests: already generated test configuration 415 416 @return List of TestConfig objects corresponding to the specific source 417 releases, minus those that were already generated elsewhere. 418 419 """ 420 generated_source_releases = [ 421 test_config.source_release for test_config in generated_tests] 422 filtered_source_releases = [rel for rel in specific_source_releases 423 if rel not in generated_source_releases] 424 if self.use_mp_images: 425 return self.generate_mp_image_specific_list( 426 filtered_source_releases) 427 else: 428 return self.generate_test_image_specific_list( 429 filtered_source_releases) 430 431 432def generate_test_list(args): 433 """Setup the test environment. 434 435 @param args: execution arguments 436 437 @return A list of test configurations. 438 439 @raise FullReleaseTestError if anything went wrong. 440 441 """ 442 # Initialize test list. 443 test_list = [] 444 # Use the full payload of the source image as the src URI rather than the 445 # test image when not using servo. 446 src_as_payload = args.servo_host == None 447 448 for board in args.tested_board_list: 449 test_list_for_board = [] 450 generator = TestConfigGenerator( 451 board, args.tested_release, 452 args.test_nmo, args.test_npo, src_as_payload, 453 args.use_mp_images, args.archive_url) 454 455 # Configure N-1-to-N and N-to-N+1 tests. 456 if args.test_nmo or args.test_npo: 457 test_list_for_board += generator.generate_npo_nmo_list() 458 459 # Add tests for specifically provided source releases. 460 if args.specific: 461 test_list_for_board += generator.generate_specific_list( 462 args.specific, test_list_for_board) 463 464 test_list += test_list_for_board 465 466 return test_list 467 468 469def run_test_local(test, env, remote): 470 """Run an end-to-end update test locally. 471 472 @param test: the test configuration 473 @param env: environment arguments for the test 474 @param remote: remote DUT address 475 476 """ 477 cmd = ['run_remote_tests.sh', 478 '--args=%s%s' % (test.get_cmdline_args(), env.get_cmdline_args()), 479 '--remote=%s' % remote, 480 '--use_emerged', 481 test_control.get_test_name()] 482 483 # Only set servo arguments if servo is in the environment. 484 if env.is_var_set('servo_host'): 485 cmd.extend(['--servo', '--allow_offline_remote']) 486 487 logging.debug('executing: %s', ' '.join(cmd)) 488 try: 489 subprocess.check_call(cmd) 490 except subprocess.CalledProcessError, e: 491 raise FullReleaseTestError( 492 'command execution failed: %s' % e) 493 494 495def run_test_afe(test, env, control_code, afe, dry_run): 496 """Run an end-to-end update test via AFE. 497 498 @param test: the test configuration 499 @param env: environment arguments for the test 500 @param control_code: content of the test control file 501 @param afe: instance of server.frontend.AFE to use to create job. 502 @param dry_run: If True, don't actually run the test against the afe. 503 504 @return The scheduled job ID or None if dry_run. 505 506 """ 507 # Parametrize the control script. 508 parametrized_control_code = test_control.generate_full_control_file( 509 test, env, control_code) 510 511 # Create the job. 512 meta_hosts = ['board:%s' % test.board] 513 514 # Only set servo arguments if servo is in the environment. 515 dependencies = ['servo'] if env.is_var_set('servo_host') else [] 516 dependencies += ['pool:suites'] 517 logging.debug('scheduling afe test: meta_hosts=%s dependencies=%s', 518 meta_hosts, dependencies) 519 if not dry_run: 520 job = afe.create_job( 521 parametrized_control_code, 522 name=test.get_autotest_name(), 523 priority=priorities.Priority.DEFAULT, 524 control_type='Server', meta_hosts=meta_hosts, 525 dependencies=dependencies) 526 return job.id 527 else: 528 logging.info('Would have run scheduled test %s against afe', test.name) 529 530 531def get_job_url(server, job_id): 532 """Returns the url for a given job status. 533 534 @param server: autotest server. 535 @param job_id: job id for the job created. 536 537 @return the url the caller can use to track the job status. 538 """ 539 # Explicitly print as this is what a caller looks for. 540 return 'Job submitted to autotest afe. To check its status go to: %s' % ( 541 _autotest_url_format % dict(host=server, job=job_id)) 542 543 544def get_boards_from_chromite(hwtest_enabled_only=False): 545 """Returns the list of boards from cbuildbot_config. 546 547 @param hwtest_enabled_only: Whether to only return boards with hw_tests 548 enabled. 549 550 @return list of boards name strings. 551 """ 552 boards = set() 553 for config in cbuildbot_config.GetConfig().itervalues(): 554 if hwtest_enabled_only and not config.get('hw_tests'): 555 continue 556 boards.update(config.get('boards')) 557 558 return list(boards) 559 560 561def parse_args(argv): 562 parser = optparse.OptionParser( 563 usage='Usage: %prog [options] RELEASE [BOARD...]', 564 description='Schedule Chrome OS release update tests on given ' 565 'board(s).') 566 567 parser.add_option('--all_boards', dest='all_boards', action='store_true', 568 help='default test run to all known boards') 569 parser.add_option('--archive_url', metavar='URL', 570 help='Use this archive url to find the target payloads.') 571 parser.add_option('--dump', default=False, action='store_true', 572 help='dump control files that would be used in autotest ' 573 'without running them. Implies --dry_run') 574 parser.add_option('--dump_dir', default=_default_dump_dir, 575 help='directory to dump control files generated') 576 parser.add_option('--nmo', dest='test_nmo', action='store_true', 577 help='generate N-1 update tests') 578 parser.add_option('--npo', dest='test_npo', action='store_true', 579 help='generate N+1 update tests') 580 parser.add_option('--specific', metavar='LIST', 581 help='comma-separated list of source releases to ' 582 'generate test configurations from') 583 parser.add_option('--servo_host', metavar='ADDR', 584 help='host running servod. Servo used only if set.') 585 parser.add_option('--servo_port', metavar='PORT', 586 help='servod port [servod default]') 587 parser.add_option('--skip_boards', dest='skip_boards', 588 help='boards to skip, separated by comma.') 589 parser.add_option('--omaha_host', metavar='ADDR', 590 help='Optional host where Omaha server will be spawned.' 591 'If not set, localhost is used.') 592 parser.add_option('--mp_images', dest='use_mp_images', action='store_true', 593 help='use MP-signed images') 594 parser.add_option('--remote', metavar='ADDR', 595 help='run test on given DUT via run_remote_tests') 596 parser.add_option('-n', '--dry_run', action='store_true', 597 help='do not invoke actual test runs') 598 parser.add_option('--log', metavar='LEVEL', dest='log_level', 599 default=_log_verbose, 600 help='verbosity level: %s' % ' '.join(_valid_log_levels)) 601 602 # Parse arguments. 603 opts, args = parser.parse_args(argv) 604 605 # Get positional arguments, adding them as option values. 606 if len(args) < 1: 607 parser.error('missing arguments') 608 609 opts.tested_release = args[0] 610 opts.tested_board_list = args[1:] 611 if not opts.tested_board_list and not opts.all_boards: 612 parser.error('No boards listed.') 613 if opts.tested_board_list and opts.all_boards: 614 parser.error('--all_boards should not be used with individual board ' 615 'arguments".') 616 617 if opts.all_boards: 618 opts.tested_board_list = get_boards_from_chromite( 619 hwtest_enabled_only=True) 620 else: 621 # Sanity check board. 622 all_boards = get_boards_from_chromite() 623 for board in opts.tested_board_list: 624 if board not in all_boards: 625 parser.error('unknown board (%s)' % board) 626 627 # Skip specific board. 628 if opts.skip_boards: 629 opts.skip_boards = opts.skip_boards.split(',') 630 opts.tested_board_list = [board for board in opts.tested_board_list 631 if board not in opts.skip_boards] 632 633 # Sanity check log level. 634 if opts.log_level not in _valid_log_levels: 635 parser.error('invalid log level (%s)' % opts.log_level) 636 637 if opts.dump: 638 if opts.remote: 639 parser.error("--remote doesn't make sense with --dump") 640 641 opts.dry_run = True 642 643 # Process list of specific source releases. 644 opts.specific = opts.specific.split(',') if opts.specific else [] 645 646 return opts 647 648 649def main(argv): 650 try: 651 # Initialize release config. 652 _release_info.initialize() 653 654 # Parse command-line arguments. 655 args = parse_args(argv) 656 657 # Set log verbosity. 658 if args.log_level == _log_debug: 659 logging.basicConfig(level=logging.DEBUG) 660 elif args.log_level == _log_verbose: 661 logging.basicConfig(level=logging.INFO) 662 else: 663 logging.basicConfig(level=logging.WARNING) 664 665 # Create test configurations. 666 test_list = generate_test_list(args) 667 if not test_list: 668 raise FullReleaseTestError( 669 'no test configurations generated, nothing to do') 670 671 # Construct environment argument, used for all tests. 672 env = test_params.TestEnv(args) 673 674 # Local or AFE invocation? 675 if args.remote: 676 # Running autoserv locally. 677 for i, test in enumerate(test_list): 678 logging.info('running test %d/%d:\n%r', i + 1, len(test_list), 679 test) 680 if not args.dry_run: 681 run_test_local(test, env, args.remote) 682 else: 683 # Obtain the test control file content. 684 with open(test_control.get_control_file_name()) as f: 685 control_code = f.read() 686 687 # Dump control file(s) to be staged later, or schedule upfront? 688 if args.dump: 689 # Populate and dump test-specific control files. 690 for test in test_list: 691 # Control files for the same board are all in the same 692 # sub-dir. 693 directory = os.path.join(args.dump_dir, test.board) 694 test_control_file = test_control.dump_autotest_control_file( 695 test, env, control_code, directory) 696 logging.info('dumped control file for test %s to %s', 697 test, test_control_file) 698 else: 699 # Schedule jobs via AFE. 700 afe = frontend.AFE(debug=(args.log_level == _log_debug)) 701 for test in test_list: 702 logging.info('scheduling test %s', test) 703 try: 704 job_id = run_test_afe(test, env, control_code, 705 afe, args.dry_run) 706 if job_id: 707 # Explicitly print as this is what a caller looks 708 # for. 709 print get_job_url(afe.server, job_id) 710 except Exception: 711 # Note we don't print the exception here as the afe 712 # will print it out already. 713 logging.error('Failed to schedule test %s. ' 714 'Please check exception and re-run this ' 715 'board manually if needed.', test) 716 717 718 except FullReleaseTestError, e: 719 logging.fatal(str(e)) 720 return 1 721 else: 722 return 0 723 724 725if __name__ == '__main__': 726 sys.exit(main(sys.argv[1:])) 727