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