Home | History | Annotate | Download | only in autoupdate
      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 
      9 By default, this runs against the AFE configured in the global_config.ini->
     10 SERVER->hostname. You can run this on a local AFE by modifying this value in
     11 your shadow_config.ini to localhost.
     12 """
     13 
     14 import logging
     15 import optparse
     16 import os
     17 import re
     18 import sys
     19 
     20 import common
     21 from autotest_lib.client.common_lib import priorities
     22 from autotest_lib.server import frontend
     23 from autotest_lib.utils import external_packages
     24 
     25 from autotest_lib.site_utils.autoupdate import import_common
     26 from autotest_lib.site_utils.autoupdate import release as release_util
     27 from autotest_lib.site_utils.autoupdate import test_image
     28 from autotest_lib.site_utils.autoupdate.lib import test_control
     29 from autotest_lib.site_utils.autoupdate.lib import test_params
     30 
     31 chromite = 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 
     51 class FullReleaseTestError(BaseException):
     52   pass
     53 
     54 
     55 def 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 
     65 class 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 
    326 def 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 
    359 def 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 
    393 def 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 
    406 def 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 
    468 def 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 
    535 if __name__ == '__main__':
    536     sys.exit(main(sys.argv[1:]))
    537