Scott Zawalski | 20a9b58 | 2011-11-21 11:49:40 -0800 | [diff] [blame] | 1 | #!/usr/bin/python |
| 2 | # |
| 3 | # Copyright (c) 2011 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 | """Tool for scheduling BVT and full suite testing of Chrome OS images. |
| 8 | |
| 9 | Test Scheduler is a tool for scheduling the testing of Chrome OS images across |
| 10 | multiple boards and platforms. All testing is driven through a board to platform |
| 11 | mapping specified in a JSON config file. |
| 12 | |
| 13 | For each board, platform tuple the bvt group is scheduled. Once the bvt has |
| 14 | completed and passed, all groups from 'default_full_groups' are scheduled. |
| 15 | |
| 16 | Test Scheduler expects the JSON config file to be in the current working |
| 17 | directory or to be run with --config pointing to the actual config file. |
| 18 | """ |
| 19 | |
| 20 | __author__ = 'dalecurtis@google.com (Dale Curtis)' |
| 21 | |
| 22 | import logging |
| 23 | import optparse |
| 24 | import os |
| 25 | import re |
| 26 | import tempfile |
| 27 | |
| 28 | from chromeos_test import autotest_util |
| 29 | from chromeos_test import common_util |
| 30 | from chromeos_test import dash_util |
| 31 | from chromeos_test import dev_server |
| 32 | from chromeos_test import log_util |
| 33 | from chromeos_test import test_config |
| 34 | |
| 35 | |
| 36 | # RegEx for extracting versions from build strings. |
| 37 | _3_TUPLE_VERSION_RE = re.compile('R\d+-(\d+\.\d+\.\d+)') |
| 38 | _4_TUPLE_VERSION_RE = re.compile('(\d+\.\d+\.\d+\.\d+)+-') |
| 39 | |
| 40 | |
| 41 | def _ParseVersion(build): |
| 42 | """Extract version from build string. Parses x.x.x.x* and Ryy-x.x.x* forms.""" |
| 43 | match = _3_TUPLE_VERSION_RE.match(build) |
| 44 | if not match: |
| 45 | match = _4_TUPLE_VERSION_RE.match(build) |
| 46 | |
| 47 | # Will generate an exception if no match was found. |
| 48 | return match.group(1) |
| 49 | |
| 50 | |
| 51 | class TestRunner(object): |
| 52 | """Helper class for scheduling jobs from tests and groups.""" |
| 53 | |
| 54 | def __init__(self, board, build, cli, config, dev, upload=False): |
| 55 | """Initializes class variables. |
| 56 | |
| 57 | Args: |
| 58 | board: Board name for this build; e.g., x86-generic-rel |
| 59 | build: Full build string to look for; e.g., 0.8.61.0-r1cf43296-b269 |
| 60 | cli: Path to Autotest CLI. |
| 61 | config: Dictionary of configuration as loaded from JSON. |
| 62 | dev: An initialized DevServer() instance. |
| 63 | upload: Whether to upload created job information to appengine. |
| 64 | """ |
| 65 | self._board = board |
| 66 | self._build = build |
| 67 | self._config = config |
| 68 | self._cli = cli |
| 69 | self._dev = dev |
| 70 | self._upload = upload |
| 71 | |
| 72 | def RunTest(self, job_name, platform, test, build=None, control_mods=None): |
| 73 | """Given a test dictionary: retrieves control file and creates jobs. |
| 74 | |
| 75 | Test dictionary format is as follows: |
| 76 | |
| 77 | {'name': '', 'control': '', 'count': ##, 'labels': [...], 'sync': <T/F>} |
| 78 | |
| 79 | Optional keys are count, labels, and sync. If not specified they will be set |
| 80 | to default values of 1, None, and False respectively. |
| 81 | |
| 82 | Jobs are created with the name <board>-<build>_<name>. |
| 83 | |
| 84 | Args: |
| 85 | job_name: Name of job to create. |
| 86 | platform: Platform to schedule job for. |
| 87 | test: Test config dictionary. |
| 88 | build: Build to use, if different than the one used to initialize class. |
| 89 | control_mods: List of functions to call for control file preprocessing. |
| 90 | Each function will be passed the contents of the control file. |
| 91 | |
| 92 | Raises: |
| 93 | common_util.ChromeOSTestError: If any steps fail. |
| 94 | """ |
| 95 | # Initialize defaults for optional keys. Avoids tedious, if <key> in <test> |
| 96 | default = {'count': 1, 'labels': None, 'sync': None} |
| 97 | default.update(test) |
| 98 | test = default |
| 99 | |
| 100 | if test['sync']: |
| 101 | test['sync'] = test['count'] |
| 102 | |
| 103 | if not build: |
| 104 | build = self._build |
| 105 | |
| 106 | # Pull control file from Dev Server. |
| 107 | try: |
| 108 | control_file_data = self._dev.GetControlFile( |
| 109 | self._board, build, test['control']) |
| 110 | except common_util.ChromeOSTestError: |
| 111 | logging.error('Missing %s for %s on %s.', test['control'], job_name, |
| 112 | platform) |
| 113 | raise |
| 114 | |
| 115 | # If there's any preprocessing to be done call it now. |
| 116 | if control_mods: |
| 117 | for mod in control_mods: |
| 118 | control_file_data = mod(control_file_data) |
| 119 | |
| 120 | # Create temporary file and write control file contents to it. |
| 121 | temp_fd, temp_fn = tempfile.mkstemp() |
| 122 | os.write(temp_fd, control_file_data) |
| 123 | os.close(temp_fd) |
| 124 | |
| 125 | # Create Autotest job using control file and image parameter. |
| 126 | try: |
| 127 | job_id = autotest_util.CreateJob( |
| 128 | name=job_name, control=temp_fn, |
| 129 | platforms='%d*%s' % (test['count'], platform), labels=test['labels'], |
| 130 | sync=test['sync'], |
| 131 | update_url=self._dev.GetUpdateUrl(self._board, build), |
| 132 | cli=self._cli) |
| 133 | finally: |
| 134 | # Cleanup temporary control file. Autotest doesn't need it anymore. |
| 135 | os.unlink(temp_fn) |
| 136 | |
| 137 | #TODO(dalecurtis): Disabled, since it's not under active development. |
| 138 | #try: |
| 139 | # appengine_cfg = self._config.get('appengine', {}) |
| 140 | # if self._upload and appengine_cfg: |
| 141 | # dash_util.UploadJob(appengine_cfg, job_id) |
| 142 | #except common_util.ChromeOSTestError: |
| 143 | # logging.warning('Failed to upload job to AppEngine.') |
| 144 | |
| 145 | def RunTestGroups(self, groups, platform, lock=True): |
| 146 | """Given a list of test groups, creates Autotest jobs for associated tests. |
| 147 | |
| 148 | Given a list of test groups, map each into the "groups" dictionary from the |
| 149 | JSON configuration file and launch associated tests. If lock is specified it |
| 150 | will attempt to acquire a dev server lock for each group before starting. If |
| 151 | a lock can't be obtained, the group won't be started. |
| 152 | |
| 153 | Args: |
| 154 | groups: List of group names to run tests for. See test config for valid |
| 155 | group names. |
| 156 | platform: Platform label to look for. See test config for valid platforms. |
| 157 | lock: Attempt to acquire lock before running tests? |
| 158 | """ |
| 159 | for group in groups: |
| 160 | if not group in self._config['groups']: |
| 161 | logging.warning('Skipping unknown group "%s".', group) |
| 162 | continue |
| 163 | |
| 164 | # Start tests for the given group. |
| 165 | for test in self._config['groups'][group]: |
| 166 | has_lock = False |
| 167 | try: |
| 168 | job_name = '%s-%s_%s' % (self._board, self._build, test['name']) |
| 169 | |
| 170 | # Attempt to acquire lock for test. |
| 171 | if lock: |
| 172 | tag = '%s/%s/%s_%s_%s' % (self._board, self._build, platform, |
| 173 | group, test['name']) |
| 174 | try: |
| 175 | self._dev.AcquireLock(tag) |
| 176 | has_lock = True |
| 177 | except common_util.ChromeOSTestError, e: |
| 178 | logging.debug('Refused lock for test "%s" from group "%s".' |
| 179 | ' Assuming it has already been started.', |
| 180 | test['name'], group) |
| 181 | continue |
| 182 | |
| 183 | self.RunTest(platform=platform, test=test, job_name=job_name) |
| 184 | logging.info('Successfully created job "%s".', job_name) |
| 185 | except common_util.ChromeOSTestError, e: |
| 186 | logging.exception(e) |
| 187 | logging.error('Failed to schedule test "%s" from group "%s".', |
| 188 | test['name'], group) |
| 189 | |
| 190 | # We failed, so release lock and let next run pick this test up. |
| 191 | if has_lock: |
| 192 | self._dev.ReleaseLock(tag) |
| 193 | |
| 194 | def RunAutoupdateTests(self, platform): |
| 195 | # Process the autoupdate targets. |
| 196 | for target in self._dev.ListAutoupdateTargets(self._board, self._build): |
| 197 | has_lock = False |
| 198 | try: |
| 199 | # Tell other instances of the scheduler we're processing this target. |
| 200 | tag = '%s/%s/%s_%s' % (self._board, self._build, platform['platform'], |
| 201 | target) |
| 202 | try: |
| 203 | self._dev.AcquireLock(tag) |
| 204 | has_lock = True |
| 205 | except common_util.ChromeOSTestError, e: |
| 206 | logging.debug('Refused lock for autoupdate target "%s". Assuming' |
| 207 | ' it has already been started.', target) |
| 208 | continue |
| 209 | |
| 210 | # Split target into base build and convenience label. |
| 211 | base_build, label = target.split('_') |
| 212 | |
| 213 | # Setup preprocessing function to insert the correct update URL into |
| 214 | # the control file. |
| 215 | control_preprocess_fn = lambda x: x % {'update_url': '%s/%s/%s' % ( |
| 216 | self._dev.GetUpdateUrl( |
| 217 | self._board, self._build), self._dev.AU_BASE, target)} |
| 218 | |
| 219 | # E.g., x86-mario-r14-0.14.734.0_to_0.14.734.0-a1-b123_nton_au |
| 220 | job_name = '%s-%s_to_%s_%s_au' % ( |
| 221 | self._board, _ParseVersion(base_build), self._build, label) |
| 222 | |
| 223 | self.RunTest( |
| 224 | platform=platform['platform'], |
| 225 | test=self._config['groups']['autoupdate'][0], job_name=job_name, |
| 226 | build=base_build, |
| 227 | control_mods=[control_preprocess_fn]) |
| 228 | logging.info('Successfully created job "%s".', job_name) |
| 229 | except common_util.ChromeOSTestError, e: |
| 230 | logging.exception(e) |
| 231 | logging.error('Failed to schedule autoupdate target "%s".', target) |
| 232 | |
| 233 | # We failed, so release lock and let next run pick this target up. |
| 234 | if has_lock: |
| 235 | self._dev.ReleaseLock(tag) |
| 236 | |
| 237 | |
| 238 | def ParseOptions(): |
| 239 | """Parse command line options. Returns 2-tuple of options and config.""" |
| 240 | parser = optparse.OptionParser('usage: %prog [options]') |
| 241 | |
| 242 | # Add utility/helper class command line options. |
| 243 | test_config.AddOptions(parser) |
| 244 | log_util.AddOptions(parser) |
| 245 | autotest_util.AddOptions(parser, cli_only=True) |
| 246 | |
| 247 | options = parser.parse_args()[0] |
| 248 | config = test_config.TestConfig(options.config) |
| 249 | |
| 250 | return options, config.GetConfig() |
| 251 | |
| 252 | |
| 253 | def main(): |
| 254 | options, config = ParseOptions() |
| 255 | |
| 256 | # Setup logger and enable verbose mode if specified. |
| 257 | log_util.InitializeLogging(options.verbose) |
| 258 | |
| 259 | # Initialize Dev Server Utility class. |
| 260 | dev = dev_server.DevServer(**config['dev_server']) |
| 261 | |
| 262 | # Main processing loop. Look for new builds of each board. |
| 263 | for board in config['boards']: |
| 264 | for platform in config['boards'][board]['platforms']: |
| 265 | logging.info('----[ Processing board %s, platform %s ]----', |
| 266 | board, platform['platform']) |
| 267 | try: |
| 268 | build = dev.GetLatestBuildVersion(board) |
| 269 | if not build: |
| 270 | continue |
| 271 | |
| 272 | test_runner = TestRunner( |
| 273 | board=board, build=build, cli=options.cli, config=config, dev=dev, |
| 274 | upload=True) |
| 275 | |
| 276 | # Determine which groups to run. |
| 277 | full_groups = [] |
| 278 | if 'groups' in platform: |
| 279 | full_groups += platform['groups'] |
| 280 | else: |
| 281 | full_groups += config['default_groups'] |
| 282 | |
| 283 | if 'extra_groups' in platform: |
| 284 | full_groups += platform['extra_groups'] |
| 285 | |
| 286 | test_runner.RunTestGroups( |
| 287 | groups=full_groups, platform=platform['platform']) |
| 288 | |
| 289 | # Skip platforms which are not marked for AU testing. |
| 290 | if not platform.get('au_test', False): |
| 291 | continue |
| 292 | |
| 293 | # Process AU targets. |
| 294 | test_runner.RunAutoupdateTests(platform) |
| 295 | except common_util.ChromeOSTestError, e: |
| 296 | logging.exception(e) |
| 297 | logging.warning('Exception encountered during processing. Skipping.') |
| 298 | |
| 299 | |
| 300 | if __name__ == '__main__': |
| 301 | main() |