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