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 sys 19 20import common 21from autotest_lib.client.common_lib import priorities 22from autotest_lib.server import frontend 23from autotest_lib.utils import external_packages 24 25from autotest_lib.site_utils.autoupdate import import_common 26from autotest_lib.site_utils.autoupdate import release as release_util 27from autotest_lib.site_utils.autoupdate import test_image 28from autotest_lib.site_utils.autoupdate.lib import test_control 29from autotest_lib.site_utils.autoupdate.lib import test_params 30 31chromite = import_common.download_and_import('chromite', 32 external_packages.ChromiteRepo()) 33 34# Autotest pylint is more restrictive than it should with args. 35#pylint: disable=C0111 36 37# Global reference objects. 38_release_info = release_util.ReleaseInfo() 39 40_log_debug = 'debug' 41_log_normal = 'normal' 42_log_verbose = 'verbose' 43_valid_log_levels = _log_debug, _log_normal, _log_verbose 44_autotest_url_format = r'http://%(host)s/afe/#tab_id=view_job&object_id=%(job)s' 45_default_dump_dir = os.path.realpath( 46 os.path.join(os.path.dirname(__file__), '..', '..', 'server', 47 'site_tests', test_control.get_test_name())) 48_build_version = '%(branch)s-%(release)s' 49 50 51class FullReleaseTestError(BaseException): 52 pass 53 54 55def get_release_branch(release): 56 """Returns the release branch for the given release. 57 58 @param release: release version e.g. 3920.0.0. 59 60 @returns the branch string e.g. R26. 61 """ 62 return _release_info.get_branch(release) 63 64 65class TestConfigGenerator(object): 66 """Class for generating test configs.""" 67 68 def __init__(self, board, tested_release, test_nmo, test_npo, 69 archive_url=None): 70 """ 71 @param board: the board under test 72 @param tested_release: the tested release version 73 @param test_nmo: whether we should infer N-1 tests 74 @param test_npo: whether we should infer N+1 tests 75 @param archive_url: optional gs url to find payloads. 76 77 """ 78 self.board = board 79 self.tested_release = tested_release 80 self.test_nmo = test_nmo 81 self.test_npo = test_npo 82 if archive_url: 83 self.archive_url = archive_url 84 else: 85 branch = get_release_branch(tested_release) 86 build_version = _build_version % dict(branch=branch, 87 release=tested_release) 88 self.archive_url = test_image.get_default_archive_url( 89 board, build_version) 90 91 # Get the prefix which is an archive_url stripped of its trailing 92 # version. We rstrip in the case of any trailing /'s. 93 # Use archive prefix for any nmo / specific builds. 94 self.archive_prefix = self.archive_url.rstrip('/').rpartition('/')[0] 95 96 97 def _get_source_uri_from_build_version(self, build_version): 98 """Returns the source_url given build version. 99 100 Args: 101 build_version: the full build version i.e. R27-3823.0.0-a2. 102 """ 103 # If we're looking for our own image, use the target archive_url if set 104 if self.tested_release in build_version: 105 archive_url = self.archive_url 106 else: 107 archive_url = test_image.get_archive_url_from_prefix( 108 self.archive_prefix, build_version) 109 110 return test_image.find_payload_uri(archive_url, single=True) 111 112 113 def _get_source_uri_from_release(self, release): 114 """Returns the source uri for a given release or None if not found. 115 116 Args: 117 release: required release number. 118 """ 119 branch = get_release_branch(release) 120 return self._get_source_uri_from_build_version( 121 _build_version % dict(branch=branch, release=release)) 122 123 124 def generate_test_image_config(self, name, is_delta_update, source_release, 125 payload_uri, source_uri): 126 """Constructs a single test config with given arguments. 127 128 It'll automatically find and populate source/target branches as well as 129 the source image URI. 130 131 @param name: a descriptive name for the test 132 @param is_delta_update: whether we're testing a delta update 133 @param source_release: the version of the source image (before update) 134 @param target_release: the version of the target image (after update) 135 @param payload_uri: URI of the update payload. 136 @param source_uri: URI of the source image/payload. 137 138 """ 139 # Extracts just the main version from a version that may contain 140 # attempts or a release candidate suffix i.e. 3928.0.0-a2 -> 141 # base_version=3928.0.0. 142 _version_re = re.compile( 143 '(?P<base_version>[0-9.]+)(?:\-[a-z]+[0-9]+])*') 144 145 # Pass only the base versions without any build specific suffixes. 146 source_version = _version_re.match(source_release).group('base_version') 147 target_version = _version_re.match(self.tested_release).group( 148 'base_version') 149 return test_params.TestConfig( 150 self.board, name, is_delta_update, source_version, 151 target_version, source_uri, payload_uri) 152 153 154 @staticmethod 155 def _parse_build_version(build_version): 156 """Returns a branch, release tuple from a full build_version. 157 158 Args: 159 build_version: build version to parse e.g. 'R27-3905.0.0' 160 """ 161 version = r'[0-9a-z.\-]+' 162 # The date portion only appears in non-release builds. 163 date = r'([0-9]+_[0-9]+_[0-9]+_[0-9]+)*' 164 # Returns groups for branches and release numbers from build version. 165 _build_version_re = re.compile( 166 '(?P<branch>R[0-9]+)-(?P<release>' + version + date + version + ')') 167 168 match = _build_version_re.match(build_version) 169 if not match: 170 logging.warning('version %s did not match version format', 171 build_version) 172 return None 173 174 return match.group('branch'), match.group('release') 175 176 177 @staticmethod 178 def _parse_delta_filename(filename): 179 """Parses a delta payload name into its source/target versions. 180 181 Args: 182 filename: Delta filename to parse e.g. 183 'chromeos_R27-3905.0.0_R27-3905.0.0_stumpy_delta_dev.bin' 184 185 Returns: tuple with source_version, and target_version. 186 """ 187 version = r'[0-9a-z.\-]+' 188 # The date portion only appears in non-release builds. 189 date = r'([0-9]+_[0-9]+_[0-9]+_[0-9]+)*' 190 # Matches delta format name and returns groups for source and target 191 # versions. 192 _delta_re = re.compile( 193 'chromeos_' 194 '(?P<s_version>R[0-9]+-' + version + date + version + ')' 195 '_' 196 '(?P<t_version>R[0-9]+-' + version + date + version + ')' 197 '_[\w.]+') 198 match = _delta_re.match(filename) 199 if not match: 200 logging.warning('filename %s did not match delta format', filename) 201 return None 202 203 return match.group('s_version'), match.group('t_version') 204 205 206 def generate_npo_nmo_list(self): 207 """Generates N+1/N-1 test configurations. 208 209 Computes a list of N+1 (npo) and/or N-1 (nmo) test configurations for a 210 given tested release and board. This is done by scanning of the test 211 image repository, looking for update payloads; normally, we expect to 212 find at most one update payload of each of the aforementioned types. 213 214 @return A list of TestConfig objects corresponding to the N+1 and N-1 215 tests. 216 217 @raise FullReleaseTestError if something went wrong 218 219 """ 220 if not (self.test_nmo or self.test_npo): 221 return [] 222 223 # Find all test delta payloads involving the release version at hand, 224 # then figure out which is which. 225 found = set() 226 test_list = [] 227 payload_uri_list = test_image.find_payload_uri( 228 self.archive_url, delta=True) 229 for payload_uri in payload_uri_list: 230 # Infer the source and target release versions. These versions will 231 # be something like 'R43-6831.0.0' for release builds and 232 # 'R43-6831.0.0-a1' for trybots. 233 file_name = os.path.basename(payload_uri) 234 source_version, target_version = ( 235 self._parse_delta_filename(file_name)) 236 _, source_release = self._parse_build_version(source_version) 237 238 # The target version should contain the tested release otherwise 239 # this is a delta payload to a different version. They are not equal 240 # since the tested_release doesn't include the milestone, for 241 # example, 940.0.1 release in the R28-940.0.1-a1 version. 242 if self.tested_release not in target_version: 243 raise FullReleaseTestError( 244 'delta target release %s does not contain %s (%s)', 245 target_version, self.tested_release, self.board) 246 247 # Search for the full payload to the source_version in the 248 # self.archive_url directory if the source_version is the tested 249 # release (such as in a npo test), or in the standard location if 250 # the source is some other build. Note that this function handles 251 # both cases. 252 source_uri = self._get_source_uri_from_build_version(source_version) 253 254 if not source_uri: 255 logging.warning('cannot find source for %s, %s', self.board, 256 source_version) 257 continue 258 259 # Determine delta type, make sure it was not already discovered. 260 delta_type = 'npo' if source_version == target_version else 'nmo' 261 # Only add test configs we were asked to test. 262 if (delta_type == 'npo' and not self.test_npo) or ( 263 delta_type == 'nmo' and not self.test_nmo): 264 continue 265 266 if delta_type in found: 267 raise FullReleaseTestError( 268 'more than one %s deltas found (%s, %s)' % ( 269 delta_type, self.board, self.tested_release)) 270 271 found.add(delta_type) 272 273 # Generate test configuration. 274 test_list.append(self.generate_test_image_config( 275 delta_type, True, source_release, payload_uri, source_uri)) 276 277 return test_list 278 279 280 def generate_specific_list(self, specific_source_releases, generated_tests): 281 """Generates test configurations for a list of specific source releases. 282 283 Returns a list of test configurations from a given list of releases to 284 the given tested release and board. Cares to exclude test configurations 285 that were already generated elsewhere (e.g. N-1/N+1). 286 287 @param specific_source_releases: list of source release to test 288 @param generated_tests: already generated test configuration 289 290 @return List of TestConfig objects corresponding to the specific source 291 releases, minus those that were already generated elsewhere. 292 293 """ 294 generated_source_releases = [ 295 test_config.source_release for test_config in generated_tests] 296 filtered_source_releases = [rel for rel in specific_source_releases 297 if rel not in generated_source_releases] 298 if not filtered_source_releases: 299 return [] 300 301 # Find the full payload for the target release. 302 tested_payload_uri = test_image.find_payload_uri( 303 self.archive_url, single=True) 304 if not tested_payload_uri: 305 logging.warning("cannot find full payload for %s, %s; no specific " 306 "tests generated", 307 self.board, self.tested_release) 308 return [] 309 310 # Construct test list. 311 test_list = [] 312 for source_release in filtered_source_releases: 313 source_uri = self._get_source_uri_from_release(source_release) 314 if not source_uri: 315 logging.warning('cannot find source for %s, %s', self.board, 316 source_release) 317 continue 318 319 test_list.append(self.generate_test_image_config( 320 'specific', False, source_release, tested_payload_uri, 321 source_uri)) 322 323 return test_list 324 325 326def generate_test_list(args): 327 """Setup the test environment. 328 329 @param args: execution arguments 330 331 @return A list of test configurations. 332 333 @raise FullReleaseTestError if anything went wrong. 334 335 """ 336 # Initialize test list. 337 test_list = [] 338 339 for board in args.tested_board_list: 340 test_list_for_board = [] 341 generator = TestConfigGenerator( 342 board, args.tested_release, args.test_nmo, args.test_npo, 343 args.archive_url) 344 345 # Configure N-1-to-N and N-to-N+1 tests. 346 if args.test_nmo or args.test_npo: 347 test_list_for_board += generator.generate_npo_nmo_list() 348 349 # Add tests for specifically provided source releases. 350 if args.specific: 351 test_list_for_board += generator.generate_specific_list( 352 args.specific, test_list_for_board) 353 354 test_list += test_list_for_board 355 356 return test_list 357 358 359def run_test_afe(test, env, control_code, afe, dry_run): 360 """Run an end-to-end update test via AFE. 361 362 @param test: the test configuration 363 @param env: environment arguments for the test 364 @param control_code: content of the test control file 365 @param afe: instance of server.frontend.AFE to use to create job. 366 @param dry_run: If True, don't actually run the test against the afe. 367 368 @return The scheduled job ID or None if dry_run. 369 370 """ 371 # Parametrize the control script. 372 parametrized_control_code = test_control.generate_full_control_file( 373 test, env, control_code) 374 375 # Create the job. 376 meta_hosts = ['board:%s' % test.board] 377 378 dependencies = ['pool:suites'] 379 logging.debug('scheduling afe test: meta_hosts=%s dependencies=%s', 380 meta_hosts, dependencies) 381 if not dry_run: 382 job = afe.create_job( 383 parametrized_control_code, 384 name=test.get_autotest_name(), 385 priority=priorities.Priority.DEFAULT, 386 control_type='Server', meta_hosts=meta_hosts, 387 dependencies=dependencies) 388 return job.id 389 else: 390 logging.info('Would have run scheduled test %s against afe', test.name) 391 392 393def get_job_url(server, job_id): 394 """Returns the url for a given job status. 395 396 @param server: autotest server. 397 @param job_id: job id for the job created. 398 399 @return the url the caller can use to track the job status. 400 """ 401 # Explicitly print as this is what a caller looks for. 402 return 'Job submitted to autotest afe. To check its status go to: %s' % ( 403 _autotest_url_format % dict(host=server, job=job_id)) 404 405 406def parse_args(argv): 407 parser = optparse.OptionParser( 408 usage='Usage: %prog [options] RELEASE [BOARD...]', 409 description='Schedule Chrome OS release update tests on given ' 410 'board(s).') 411 412 parser.add_option('--archive_url', metavar='URL', 413 help='Use this archive url to find the target payloads.') 414 parser.add_option('--dump', default=False, action='store_true', 415 help='dump control files that would be used in autotest ' 416 'without running them. Implies --dry_run') 417 parser.add_option('--dump_dir', default=_default_dump_dir, 418 help='directory to dump control files generated') 419 parser.add_option('--nmo', dest='test_nmo', action='store_true', 420 help='generate N-1 update tests') 421 parser.add_option('--npo', dest='test_npo', action='store_true', 422 help='generate N+1 update tests') 423 parser.add_option('--specific', metavar='LIST', 424 help='comma-separated list of source releases to ' 425 'generate test configurations from') 426 parser.add_option('--skip_boards', dest='skip_boards', 427 help='boards to skip, separated by comma.') 428 parser.add_option('--omaha_host', metavar='ADDR', 429 help='Optional host where Omaha server will be spawned.' 430 'If not set, localhost is used.') 431 parser.add_option('-n', '--dry_run', action='store_true', 432 help='do not invoke actual test runs') 433 parser.add_option('--log', metavar='LEVEL', dest='log_level', 434 default=_log_verbose, 435 help='verbosity level: %s' % ' '.join(_valid_log_levels)) 436 437 # Parse arguments. 438 opts, args = parser.parse_args(argv) 439 440 # Get positional arguments, adding them as option values. 441 if len(args) < 1: 442 parser.error('missing arguments') 443 444 opts.tested_release = args[0] 445 opts.tested_board_list = args[1:] 446 if not opts.tested_board_list: 447 parser.error('No boards listed.') 448 449 # Skip specific board. 450 if opts.skip_boards: 451 opts.skip_boards = opts.skip_boards.split(',') 452 opts.tested_board_list = [board for board in opts.tested_board_list 453 if board not in opts.skip_boards] 454 455 # Sanity check log level. 456 if opts.log_level not in _valid_log_levels: 457 parser.error('invalid log level (%s)' % opts.log_level) 458 459 if opts.dump: 460 opts.dry_run = True 461 462 # Process list of specific source releases. 463 opts.specific = opts.specific.split(',') if opts.specific else [] 464 465 return opts 466 467 468def main(argv): 469 try: 470 # Initialize release config. 471 _release_info.initialize() 472 473 # Parse command-line arguments. 474 args = parse_args(argv) 475 476 # Set log verbosity. 477 if args.log_level == _log_debug: 478 logging.basicConfig(level=logging.DEBUG) 479 elif args.log_level == _log_verbose: 480 logging.basicConfig(level=logging.INFO) 481 else: 482 logging.basicConfig(level=logging.WARNING) 483 484 # Create test configurations. 485 test_list = generate_test_list(args) 486 if not test_list: 487 raise FullReleaseTestError( 488 'no test configurations generated, nothing to do') 489 490 # Construct environment argument, used for all tests. 491 env = test_params.TestEnv(args) 492 493 # Obtain the test control file content. 494 with open(test_control.get_control_file_name()) as f: 495 control_code = f.read() 496 497 # Dump control file(s) to be staged later, or schedule upfront? 498 if args.dump: 499 # Populate and dump test-specific control files. 500 for test in test_list: 501 # Control files for the same board are all in the same 502 # sub-dir. 503 directory = os.path.join(args.dump_dir, test.board) 504 test_control_file = test_control.dump_autotest_control_file( 505 test, env, control_code, directory) 506 logging.info('dumped control file for test %s to %s', 507 test, test_control_file) 508 else: 509 # Schedule jobs via AFE. 510 afe = frontend.AFE(debug=(args.log_level == _log_debug)) 511 for test in test_list: 512 logging.info('scheduling test %s', test) 513 try: 514 job_id = run_test_afe(test, env, control_code, 515 afe, args.dry_run) 516 if job_id: 517 # Explicitly print as this is what a caller looks 518 # for. 519 print get_job_url(afe.server, job_id) 520 except Exception: 521 # Note we don't print the exception here as the afe 522 # will print it out already. 523 logging.error('Failed to schedule test %s. ' 524 'Please check exception and re-run this ' 525 'board manually if needed.', test) 526 527 528 except FullReleaseTestError, e: 529 logging.fatal(str(e)) 530 return 1 531 else: 532 return 0 533 534 535if __name__ == '__main__': 536 sys.exit(main(sys.argv[1:])) 537