| # Copyright 2013 The Chromium Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| import argparse |
| import ast |
| import contextlib |
| import datetime |
| import difflib |
| import random |
| import re |
| import urllib |
| |
| from builders import EmptyTestSpec, TestStepConfig, TestSpec |
| from recipe_engine.types import freeze |
| from recipe_engine import recipe_api |
| from . import bisection |
| from . import builders |
| from . import testing |
| |
| |
| CBE_URL = 'http://chrome-build-extract.appspot.com' |
| V8_URL = 'https://chromium.googlesource.com/v8/v8' |
| |
| COMMIT_TEMPLATE = '%s/+/%%s' % V8_URL |
| |
| # Regular expressions for v8 branch names. |
| RELEASE_BRANCH_RE = re.compile(r'^(?:refs/branch-heads/)?\d+\.\d+$') |
| |
| # With more than 23 letters, labels are to big for buildbot's popup boxes. |
| MAX_LABEL_SIZE = 23 |
| |
| # Make sure that a step is not flooded with log lines. |
| MAX_FAILURE_LOGS = 10 |
| |
| # Factor by which the considered failure for bisection must be faster than the |
| # ongoing build's total. |
| BISECT_DURATION_FACTOR = 5 |
| |
| TEST_RUNNER_PARSER = argparse.ArgumentParser() |
| TEST_RUNNER_PARSER.add_argument('--extra-flags') |
| |
| VERSION_LINE_RE = r'^#define %s\s+(\d*)$' |
| VERSION_LINE_REPLACEMENT = '#define %s %s' |
| V8_MAJOR = 'V8_MAJOR_VERSION' |
| V8_MINOR = 'V8_MINOR_VERSION' |
| V8_BUILD = 'V8_BUILD_NUMBER' |
| V8_PATCH = 'V8_PATCH_LEVEL' |
| |
| LCOV_IMAGE = 'lcov:2018-01-18_17-03' |
| |
| |
| class V8Version(object): |
| """A v8 version as used for tagging (with patch level), e.g. '3.4.5.1'.""" |
| |
| def __init__(self, major, minor, build, patch): |
| self.major = major |
| self.minor = minor |
| self.build = build |
| self.patch = patch |
| |
| def __eq__(self, other): |
| return (self.major == other.major and |
| self.minor == other.minor and |
| self.build == other.build and |
| self.patch == other.patch) |
| |
| def __str__(self): |
| patch_str = '.%s' % self.patch if self.patch and self.patch != '0' else '' |
| return '%s.%s.%s%s' % (self.major, self.minor, self.build, patch_str) |
| |
| def with_incremented_patch(self): |
| return V8Version( |
| self.major, self.minor, self.build, str(int(self.patch) + 1)) |
| |
| def update_version_file_blob(self, blob): |
| """Takes a version file's text and returns it with this object's version. |
| """ |
| def sub(label, value, text): |
| return re.sub( |
| VERSION_LINE_RE % label, |
| VERSION_LINE_REPLACEMENT % (label, value), |
| text, |
| flags=re.M, |
| ) |
| blob = sub(V8_MAJOR, self.major, blob) |
| blob = sub(V8_MINOR, self.minor, blob) |
| blob = sub(V8_BUILD, self.build, blob) |
| return sub(V8_PATCH, self.patch, blob) |
| |
| |
| class V8Api(recipe_api.RecipeApi): |
| BUILDERS = builders.BUILDERS |
| FLATTENED_BUILDERS = builders.FLATTENED_BUILDERS |
| VERSION_FILE = 'include/v8-version.h' |
| EMPTY_TEST_SPEC = EmptyTestSpec |
| TEST_SPEC = TestSpec |
| |
| def bot_config_by_buildername(self, builders=FLATTENED_BUILDERS): |
| default = {} |
| if not self.m.properties.get('parent_buildername'): |
| # Builders and builder_testers both build and need the following set of |
| # default chromium configs: |
| default['chromium_apply_config'] = ['default_compiler', 'goma', 'mb'] |
| return builders.get(self.m.properties.get('buildername'), default) |
| |
| def update_bot_config(self, bot_config, build_config, enable_swarming, |
| target_arch, target_platform, triggers): |
| """Update bot_config dict with src-side properties. |
| |
| Args: |
| bot_config: The bot_config dict to update. |
| build_config: Config value for BUILD_CONFIG in chromium recipe module. |
| enable_swarming: Switch to enable/disable swarming. |
| target_arch: Config value for TARGET_ARCH in chromium recipe module. |
| target_platform: Config value for TARGET_PLATFORM in chromium recipe |
| module. |
| triggers: List of tester names to trigger on success. |
| |
| Returns: |
| An updated copy of the bot_config dict. |
| """ |
| # Make mutable copy. |
| bot_config = dict(bot_config) |
| bot_config['v8_config_kwargs'] = dict( |
| bot_config.get('v8_config_kwargs', {})) |
| # Update only specified properties. |
| for k, v in ( |
| ('BUILD_CONFIG', build_config), |
| ('TARGET_ARCH', target_arch), |
| ('TARGET_PLATFORM', target_platform)): |
| if v is not None: |
| bot_config['v8_config_kwargs'][k] = v |
| if enable_swarming is not None: |
| bot_config['enable_swarming'] = enable_swarming |
| # Make mutable copy. |
| bot_config['triggers'] = list(bot_config.get('triggers', [])) |
| bot_config['triggers'].extend(triggers or []) |
| # TODO(machenbach): Temporarily also dedupe, during migrating triggers src |
| # side. Should be removed when everything has migrated. |
| bot_config['triggers'] = sorted(list(set(bot_config['triggers']))) |
| return bot_config |
| |
| def get_test_roots(self): |
| """Returns the list of default and extensible test root directories. |
| |
| A test root is a directory with the following layout: |
| <root>/infra/testing/config.pyl (optional) |
| <root>/infra/testing/builders.pyl |
| <root>/test/<test suites> (optional) |
| |
| By default, the V8 checkout is a test root, and all matching directories |
| under v8/custom_deps. |
| |
| Returns: List of paths to test roots. |
| """ |
| result = [self.m.path['checkout']] |
| custom_deps_dir = self.m.path['checkout'].join('custom_deps') |
| self.m.file.ensure_directory('ensure custom_deps dir', custom_deps_dir) |
| for path in self.m.file.listdir('list test roots', custom_deps_dir): |
| if self.m.path.exists(path.join('infra', 'testing', 'builders.pyl')): |
| assert self.bot_type == 'builder_tester', ( |
| 'Separate test checkouts are only supported on builder_testers. ' |
| 'For separate builders and testers, the test configs need to be ' |
| 'transferred as properties') |
| result.append(path) |
| return result |
| |
| def update_test_configs(self, test_configs): |
| """Update test configs without mutating previous copy.""" |
| self.test_configs = dict(getattr(self, 'test_configs', {})) |
| self.test_configs.update(test_configs) |
| |
| def load_static_test_configs(self): |
| """Set predifined test configs from build repository.""" |
| self.update_test_configs(testing.TEST_CONFIGS) |
| |
| def load_dynamic_test_configs(self, root): |
| """Add test configs from configured location. |
| |
| The test configs in <root>/infra/testing/config.pyl are expected to follow |
| the same structure as the TEST_CONFIGS dict in testing.py. |
| |
| Args: |
| test_checkout: Path to test root, can either be the V8 checkout or an |
| additional test checkout. |
| Returns: Test config dict. |
| """ |
| test_config_path = root.join('infra', 'testing', 'config.pyl') |
| |
| # Fallback for branch builders. |
| if not self.m.path.exists(test_config_path): |
| return {} |
| |
| try: |
| # Eval python literal file. |
| test_configs = ast.literal_eval(self.m.file.read_text( |
| 'read test config (%s)' % self.m.path.basename(root), |
| test_config_path, |
| test_data='{}', |
| )) |
| except SyntaxError as e: # pragma: no cover |
| raise self.m.step.InfraFailure( |
| 'Failed to parse test config "%s": %s' % (test_config_path, e)) |
| |
| for test_config in test_configs.itervalues(): |
| # This configures the test runner to set the test root to the |
| # test_checkout location for all tests from this checkout. |
| # TODO(machenbach): This is starting to get hacky. The test config |
| # dicts should be refactored into classes similar to test specs. Maybe |
| # the extra configurations from test configs could be added to test |
| # specs. |
| test_config['test_root'] = str(root.join('test')) |
| |
| return test_configs |
| |
| def apply_bot_config(self, bot_config): |
| """Entry method for using the v8 api.""" |
| self.bot_config = bot_config |
| |
| kwargs = {} |
| if self.m.properties.get('parent_build_config'): |
| kwargs['BUILD_CONFIG'] = self.m.properties['parent_build_config'] |
| kwargs.update(self.bot_config.get('v8_config_kwargs', {})) |
| |
| self.set_config('v8', optional=True, **kwargs) |
| self.m.chromium.set_config('v8', **kwargs) |
| self.m.gclient.set_config('v8', **kwargs) |
| |
| if self.m.chromium.c.TARGET_PLATFORM in ['android', 'fuchsia']: |
| self.m.gclient.apply_config(self.m.chromium.c.TARGET_PLATFORM) |
| |
| for c in self.bot_config.get('gclient_apply_config', []): |
| self.m.gclient.apply_config(c) |
| for c in self.bot_config.get('chromium_apply_config', []): |
| self.m.chromium.apply_config(c) |
| for c in self.bot_config.get('v8_apply_config', []): |
| self.apply_config(c) |
| |
| # Infer gclient variable that instructs sysroot download. |
| if (self.m.chromium.c.TARGET_PLATFORM != 'android' and |
| self.m.chromium.c.TARGET_ARCH == 'arm'): |
| # This grabs both sysroots to not be dependent on additional bitness |
| # setting. |
| self.m.gclient.c.target_cpu.add('arm') |
| self.m.gclient.c.target_cpu.add('arm64') |
| |
| if self.bot_config.get('enable_swarming', True): |
| self.m.gclient.c.got_revision_reverse_mapping[ |
| 'got_swarming_client_revision'] = ('v8/tools/swarming_client') |
| |
| # FIXME(machenbach): Use a context object that stores the state for each |
| # test process. Otherwise it's easy to introduce bugs with multiple test |
| # processes and stale context data. E.g. during bisection these values |
| # change for tests on rerun. |
| |
| # Default failure retry. |
| self.rerun_failures_count = 2 |
| |
| # If tests are run, this value will be set to their total duration. |
| self.test_duration_sec = 0 |
| |
| # Allow overriding the isolate hashes during bisection with the ones that |
| # correspond to the build of a bisect step. |
| self._isolated_tests_override = None |
| |
| # Cache to compute isolated targets only once. |
| self._isolate_targets_cached = [] |
| |
| # This is inferred from the run_mb step or from the parent bot. If mb is |
| # run multiple times, it is overwritten. It contains gn arguments. |
| self.build_environment = self.m.properties.get( |
| 'parent_build_environment', {}) |
| |
| def set_gclient_custom_var(self, var_name): |
| """Sets the gclient custom var `var_name` if given. |
| |
| This customizes gclient sync, based on conditions on the variable in the |
| V8 DEPS file. |
| """ |
| if var_name: |
| self.m.gclient.c.solutions[0].custom_vars[var_name] = 'True' |
| |
| @property |
| def isolated_tests(self): |
| # During bisection, the isolated hashes will be updated with hashes that |
| # correspond to the bisect step. |
| # TODO(machenbach): Remove pragma as soon as rerun is implemented for |
| # swarming. |
| if self._isolated_tests_override is not None: # pragma: no cover |
| return self._isolated_tests_override |
| return self.m.isolate.isolated_tests |
| |
| def testing_random_seed(self): |
| """Return a random seed suitable for v8 testing. |
| |
| If there are isolate hashes, build a random seed based on the hashes. |
| Otherwise use the system's PRNG. This uses a deterministic seed for |
| recipe simulation. |
| """ |
| r = random.Random() |
| if self.isolated_tests: |
| r.seed(tuple(self.isolated_tests)) |
| elif self._test_data.enabled: |
| r.seed(12345) |
| |
| seed = 0 |
| while not seed: |
| # Avoid 0 because v8 switches off usage of random seeds when |
| # passing 0 and creates a new one. |
| seed = r.randint(-2147483648, 2147483647) |
| return seed |
| |
| def checkout(self, revision=None, **kwargs): |
| # Set revision for bot_update. |
| revision = revision or self.m.properties.get( |
| 'parent_got_revision', self.m.properties.get('revision', 'HEAD')) |
| solution = self.m.gclient.c.solutions[0] |
| branch = self.m.properties.get('branch', 'master') |
| if RELEASE_BRANCH_RE.match(branch): |
| if branch.startswith('refs/branch-heads/'): |
| revision = '%s:%s' % (branch, revision) |
| else: |
| # TODO(sergiyb): Deprecate this after migrating branch builders to LUCI. |
| revision = 'refs/branch-heads/%s:%s' % (branch, revision) |
| solution.revision = revision |
| |
| self.checkout_root = self.m.path['builder_cache'] |
| if self.m.runtime.is_luci: |
| self.checkout_root = self.m.path['builder_cache'] |
| else: |
| # TODO(sergiyb): Deprecate this after migrating all builders to LUCI. |
| safe_buildername = ''.join( |
| c if c.isalnum() else '_' for c in self.m.properties['buildername']) |
| self.checkout_root = self.m.path['builder_cache'].join(safe_buildername) |
| |
| self.m.file.ensure_directory( |
| 'ensure builder cache dir', self.checkout_root) |
| with self.m.context(cwd=self.checkout_root): |
| update_step = self.m.bot_update.ensure_checkout(**kwargs) |
| |
| assert update_step.json.output['did_run'] |
| |
| # Bot_update maintains the properties independent of the UI |
| # presentation. |
| self.revision = self.m.bot_update.last_returned_properties['got_revision'] |
| |
| # Note, a commit position might not be available on feature branches. |
| self.revision_cp = ( |
| self.m.bot_update.last_returned_properties.get('got_revision_cp')) |
| self.revision_number = None |
| if self.revision_cp: |
| self.revision_number = str(self.m.commit_position.parse_revision( |
| self.revision_cp)) |
| |
| return update_step |
| |
| def calculate_patch_base_gerrit(self): |
| """Calculates the commit hash a gerrit patch was branched off.""" |
| commits, _ = self.m.gitiles.log( |
| url=V8_URL, |
| ref='master..%s' % self.m.properties['patch_ref'], |
| limit=100, |
| step_name='Get patches', |
| step_test_data=lambda: self.test_api.example_patch_range(), |
| ) |
| # There'll be at least one commit with the patch. Maybe more for dependent |
| # CLs. |
| assert len(commits) >= 1 |
| # We don't support merges. |
| assert len(commits[-1]['parents']) == 1 |
| return commits[-1]['parents'][0] |
| |
| def set_up_swarming(self): |
| if self.bot_config.get('enable_swarming', True): |
| self.m.swarming.check_client_version() |
| |
| self.m.swarming.set_default_dimension('pool', 'Chrome') |
| self.m.swarming.set_default_dimension('os', 'Ubuntu-14.04') |
| # TODO(machenbach): Investigate if this is causing a priority inversion |
| # with tasks not specifying cores=8. See http://crbug.com/735388 |
| # self.m.swarming.set_default_dimension('cores', '8') |
| self.m.swarming.add_default_tag('project:v8') |
| self.m.swarming.default_hard_timeout = 45 * 60 |
| |
| self.m.swarming.default_idempotent = True |
| |
| if self.m.properties['mastername'] == 'tryserver.v8': |
| self.m.swarming.add_default_tag('purpose:pre-commit') |
| requester = self.m.properties.get('requester') |
| if requester == 'commit-bot@chromium.org': |
| self.m.swarming.default_priority = 30 |
| self.m.swarming.add_default_tag('purpose:CQ') |
| blamelist = self.m.properties.get('blamelist') |
| if len(blamelist) == 1: |
| requester = blamelist[0] |
| else: |
| self.m.swarming.default_priority = 28 |
| self.m.swarming.add_default_tag('purpose:ManualTS') |
| self.m.swarming.default_user = requester |
| |
| patch_project = self.m.properties.get('patch_project') |
| if patch_project: |
| self.m.swarming.add_default_tag('patch_project:%s' % patch_project) |
| else: |
| if self.m.properties['mastername'] in ['client.v8', 'client.v8.ports']: |
| self.m.swarming.default_priority = 25 |
| else: |
| # This should be lower than the CQ. |
| self.m.swarming.default_priority = 35 |
| self.m.swarming.add_default_tag('purpose:post-commit') |
| self.m.swarming.add_default_tag('purpose:CI') |
| |
| if self.m.runtime.is_experimental: |
| # Use lower priority for tasks scheduled from experimental builds. |
| self.m.swarming.default_priority = 60 |
| |
| def runhooks(self, **kwargs): |
| if (self.m.chromium.c.compile_py.compiler and |
| self.m.chromium.c.compile_py.compiler.startswith('goma')): |
| # Only ensure goma if we want to use it. Otherwise it might break bots |
| # that don't support the goma executables. |
| self.m.chromium.ensure_goma() |
| env = {} |
| self.m.chromium.runhooks(env=env, **kwargs) |
| |
| @property |
| def bot_type(self): |
| if self.bot_config.get('triggers') or self.bot_config.get('triggers_proxy'): |
| return 'builder' |
| if self.m.properties.get('parent_buildername'): |
| return 'tester' |
| return 'builder_tester' |
| |
| @property |
| def builderset(self): |
| """Returns a list of names of this builder and all its triggered testers.""" |
| return ( |
| [self.m.properties['buildername']] + |
| list(self.bot_config.get('triggers', [])) |
| ) |
| |
| @property |
| def should_build(self): |
| return self.bot_type in ['builder', 'builder_tester'] |
| |
| @property |
| def should_test(self): |
| return self.bot_type in ['tester', 'builder_tester'] |
| |
| @property |
| def should_upload_build(self): |
| return (self.bot_config.get('triggers_proxy') or |
| self.bot_config.get('should_upload_build')) |
| |
| @property |
| def should_download_build(self): |
| return self.bot_type == 'tester' and not self.is_pure_swarming_tester |
| |
| @property |
| def relative_path_to_d8(self): |
| return self.m.path.join('out', self.m.chromium.c.build_config_fs, 'd8') |
| |
| def extra_tests_from_properties(self): |
| """Returns runnable testing.BaseTest objects for each extra test specified |
| by parent_test_spec property. |
| """ |
| return [ |
| testing.create_test(test, self.m) |
| for test in TestSpec.from_properties_dict(self.m.properties) |
| ] |
| |
| def extra_tests_from_test_spec(self, test_spec): |
| """Returns runnable testing.BaseTest objects for each extra test specified |
| in the test spec of the current builder. |
| """ |
| return [ |
| testing.create_test(test, self.m) |
| for test in test_spec.get_tests(self.m.properties['buildername']) |
| ] |
| |
| def dedupe_tests(self, high_prec_tests, low_prec_tests): |
| """Dedupe tests with lower precedence.""" |
| high_prec_ids = set([test.id for test in high_prec_tests]) |
| return high_prec_tests + [ |
| test for test in low_prec_tests if test.id not in high_prec_ids] |
| |
| def read_test_spec(self, root): |
| """Reads a test specification file under <root>/infra/testing/builders.pyl. |
| |
| Args: |
| root: Path to checkout root with configurations. |
| Returns: TestSpec object, filtered by interesting builders (current builder |
| and all its triggered testers). |
| """ |
| test_spec_file = root.join('infra', 'testing', 'builders.pyl') |
| |
| # Fallback for branch builders. |
| if not self.m.path.exists(test_spec_file): |
| return EmptyTestSpec |
| |
| try: |
| # Eval python literal file. |
| full_test_spec = ast.literal_eval(self.m.file.read_text( |
| 'read test spec (%s)' % self.m.path.basename(root), |
| test_spec_file, |
| test_data='{}', |
| )) |
| except SyntaxError as e: # pragma: no cover |
| raise self.m.step.InfraFailure( |
| 'Failed to parse test specification "%s": %s' % (test_spec_file, e)) |
| |
| # Transform into object. |
| test_spec = TestSpec.from_python_literal(full_test_spec, self.builderset) |
| |
| # Log test spec for debuggability. |
| self.m.step.active_result.presentation.logs['test_spec'] = ( |
| test_spec.log_lines()) |
| |
| return test_spec |
| |
| def isolate_targets_from_tests(self, tests): |
| """Returns the isolated targets associated with a list of tests. |
| |
| Args: |
| tests: A list of test names used as keys in the V8 API's test config. |
| """ |
| if not self.bot_config.get('enable_swarming', True): |
| return [] |
| targets = [] |
| for test in tests: |
| config = self.test_configs.get(test) or {} |
| |
| # Tests either define an explicit isolate target or use the test |
| # names for convenience. |
| if config.get('isolated_target'): |
| targets.append(config['isolated_target']) |
| elif config.get('tests'): |
| targets.extend(config['tests']) |
| return targets |
| |
| @property |
| def isolate_targets(self): |
| """Returns the isolate targets statically known from builders.py.""" |
| if self._isolate_targets_cached: |
| return self._isolate_targets_cached |
| |
| if self.bot_config.get('enable_swarming', True): |
| # Find tests to isolate on builders. |
| for buildername in self.builderset: |
| bot_config = builders.FLATTENED_BUILDERS.get(buildername, {}) |
| self._isolate_targets_cached.extend( |
| self.isolate_targets_from_tests( |
| [test.name for test in bot_config.get('tests', [])])) |
| |
| # Add the performance-tests isolate everywhere, where the perf-bot proxy |
| # is triggered. |
| if self.bot_config.get('triggers_proxy', False): |
| self._isolate_targets_cached.append('perf') |
| |
| self._isolate_targets_cached = sorted(list(set( |
| self._isolate_targets_cached))) |
| return self._isolate_targets_cached |
| |
| def isolate_tests(self, isolate_targets): |
| """Upload isolated tests to isolate server. |
| |
| Args: |
| isolate_targets: Targets to isolate. |
| """ |
| if isolate_targets: |
| self.m.isolate.isolate_tests( |
| self.m.chromium.output_dir, |
| targets=isolate_targets, |
| verbose=True, |
| swarm_hashes_property_name=None, |
| ) |
| self.upload_isolated_json() |
| |
| def _update_build_environment(self, mb_output): |
| """Sets the build_environment property based on gn arguments in mb output. |
| """ |
| self.build_environment = {} |
| # Check if the output looks like gn. Space-join all gn args, except |
| # goma_dir. |
| # TODO(machenbach): Instead of scanning the output, we could also read |
| # the gn.args file that was written. |
| match = re.search(r'Writing """\\?\s*(.*)""" to ', mb_output, re.S) |
| if match: |
| self.build_environment['gn_args'] = ' '.join( |
| l for l in match.group(1).strip().splitlines() |
| if not l.startswith('goma_dir')) |
| |
| def _upload_build_dependencies(self, deps): |
| values = { |
| 'ext_h_avg_deps': deps['by_extension']['h']['avg_deps'], |
| 'ext_h_top100_avg_deps': deps['by_extension']['h']['top100_avg_deps'], |
| 'ext_h_top200_avg_deps': deps['by_extension']['h']['top200_avg_deps'], |
| 'ext_h_top500_avg_deps': deps['by_extension']['h']['top500_avg_deps'], |
| } |
| points = [] |
| root = '/'.join([ |
| 'v8.infra.experimental' if self.m.runtime.is_experimental else 'v8.infra', |
| 'build_dependencies', |
| '', |
| ]) |
| for k, v in values.iteritems(): |
| p = self.m.perf_dashboard.get_skeleton_point( |
| root + k, self.revision_number, str(v)) |
| p['units'] = 'count' |
| p['supplemental_columns'] = { |
| 'a_default_rev': 'r_v8_git', |
| 'r_v8_git': self.revision, |
| } |
| points.append(p) |
| if points: |
| self.m.perf_dashboard.add_point(points) |
| |
| def _track_binary_size(self, path_pieces_list, category): |
| """Track and upload binary size of configured binaries. |
| |
| Args: |
| path_pieces_list: List of path pieces to be joined to the build output |
| folder respectively. Each path should point to a binary to track. |
| category: ChromePerf category for qualifying the graph names, e.g. |
| linux32 or linux64. |
| """ |
| files = [ |
| self.build_output_dir.join(*path_pieces) |
| for path_pieces in path_pieces_list |
| ] |
| |
| sizes = self.m.file.filesizes('Check binary size', files) |
| |
| point_defaults = { |
| 'units': 'bytes', |
| 'supplemental_columns': { |
| 'a_default_rev': 'r_v8_git', |
| 'r_v8_git': self.revision, |
| }, |
| } |
| |
| if self.m.runtime.is_experimental: |
| trace_prefix = ['v8.infra.experimental'] |
| else: |
| trace_prefix = ['v8.infra'] |
| |
| trace_prefix.append('binary_size') |
| |
| points = [] |
| for path_pieces, size in zip(path_pieces_list, sizes): |
| p = self.m.perf_dashboard.get_skeleton_point( |
| '/'.join(trace_prefix + [path_pieces[-1]]), |
| self.revision_number, |
| str(size), |
| bot=category, |
| ) |
| p.update(point_defaults) |
| points.append(p) |
| self.m.perf_dashboard.add_point(points) |
| |
| def compile(self, test_spec=EmptyTestSpec, **kwargs): |
| """Compile all desired targets and isolate tests. |
| |
| Args: |
| test_spec: Optional TestSpec object as returned by read_test_spec(). |
| Expected to contain only specifications for the current builder and |
| all triggered builders. All corrensponding extra targets will also be |
| isolated. |
| """ |
| use_goma = (self.m.chromium.c.compile_py.compiler and |
| 'goma' in self.m.chromium.c.compile_py.compiler) |
| |
| # Calculate extra targets to isolate from V8-side test specification. The |
| # test_spec contains extra TestStepConfig objects for the current builder |
| # and all its triggered builders. |
| extra_targets = self.isolate_targets_from_tests( |
| test_spec.get_all_test_names()) |
| isolate_targets = sorted(list(set(self.isolate_targets + extra_targets))) |
| |
| if self.m.chromium.c.project_generator.tool == 'mb': |
| def step_test_data(): |
| # Fake MB output with GN flags. |
| return self.m.raw_io.test_api.stream_output( |
| 'Writing """\\\n' |
| 'goma_dir = "/b/build/slave/cache/goma_client"\n' |
| 'target_cpu = "x86"\n' |
| 'use_goma = true\n' |
| '""" to /b/build/slave/linux-builder/build/v8/out/Release/args.gn\n' |
| 'moar text' |
| ) |
| try: |
| self.m.chromium.run_mb( |
| self.m.properties['mastername'], |
| self.m.properties['buildername'], |
| use_goma=use_goma, |
| mb_config_path=self.m.path['checkout'].join( |
| 'infra', 'mb', 'mb_config.pyl'), |
| isolated_targets=isolate_targets, |
| stdout=self.m.raw_io.output_text(), |
| step_test_data=step_test_data, |
| ) |
| finally: |
| # Log captured output. We call log below 'captured_stdout' instead of |
| # simply 'stdout' to differentiate it from the default 'stdout' log that |
| # is added to all steps. The latter log will actually contain no real |
| # output because it is captured by the raw_io module. |
| self.m.step.active_result.presentation.logs['captured_stdout'] = ( |
| self.m.step.active_result.stdout.splitlines()) |
| |
| # Update the build environment dictionary, which is printed to the |
| # user on test failures for easier build reproduction. |
| self._update_build_environment(self.m.step.active_result.stdout) |
| |
| # Create logs surfacing GN arguments. This information is critical to |
| # developers for reproducing failures locally. |
| if 'gn_args' in self.build_environment: |
| self.m.step.active_result.presentation.logs['gn_args'] = ( |
| self.build_environment['gn_args'].splitlines()) |
| elif self.m.chromium.c.project_generator.tool == 'gn': |
| self.m.chromium.run_gn(use_goma=use_goma) |
| |
| if use_goma: |
| kwargs['use_goma_module'] = True |
| self.m.chromium.compile(**kwargs) |
| |
| if self.bot_config.get('track_build_dependencies', False): |
| deps = self.m.python( |
| name='track build dependencies (fyi)', |
| script=self.resource('build-dep-stats.py'), |
| args=[ |
| '-C', self.build_output_dir, |
| '-x', '/third_party/', |
| '-o', self.m.json.output(), |
| ], |
| step_test_data=lambda: self.test_api.example_build_dependencies(), |
| ok_ret='any', |
| ).json.output |
| if deps: |
| self._upload_build_dependencies(deps) |
| |
| # Track binary size if specified. |
| tracking_config = self.bot_config.get('binary_size_tracking', {}) |
| if tracking_config: |
| self._track_binary_size( |
| tracking_config['path_pieces_list'], |
| tracking_config['category'], |
| ) |
| |
| self.isolate_tests(isolate_targets) |
| |
| def _get_default_archive(self): |
| return 'gs://chromium-v8/%sarchives/%s/%s' % ( |
| 'experimental/' if self.m.runtime.is_experimental else '', |
| self.m.properties['mastername'], |
| self.m.properties['buildername'], |
| ) |
| |
| def upload_build(self, name_suffix='', archive=None): |
| self.m.archive.zip_and_upload_build( |
| 'package build' + name_suffix, |
| self.m.chromium.c.build_config_fs, |
| archive or self._get_default_archive(), |
| src_dir=self.checkout_root.join('v8')) |
| |
| @property |
| def isolated_archive_path(self): |
| buildername = (self.m.properties.get('parent_buildername') or |
| self.m.properties['buildername']) |
| return 'chromium-v8/%sisolated/%s/%s' % ( |
| 'experimental/' if self.m.runtime.is_experimental else '', |
| self.m.properties['mastername'], |
| buildername, |
| ) |
| |
| def upload_isolated_json(self): |
| self.m.gsutil.upload( |
| self.m.json.input(self.m.isolate.isolated_tests), |
| self.isolated_archive_path, |
| '%s.json' % self.revision, |
| args=['-a', 'public-read'], |
| ) |
| |
| def maybe_create_clusterfuzz_archive(self, update_step): |
| if self.bot_config.get('cf_archive_build', False): |
| kwargs = {} |
| if self.bot_config.get('cf_archive_bitness'): |
| kwargs['use_legacy'] = False |
| kwargs['bitness'] = self.bot_config['cf_archive_bitness'] |
| self.m.archive.clusterfuzz_archive( |
| revision_dir='v8', |
| build_dir=self.build_output_dir, |
| update_properties=update_step.presentation.properties, |
| gs_bucket=self.bot_config.get('cf_gs_bucket'), |
| gs_acl=self.bot_config.get('cf_gs_acl'), |
| archive_prefix=self.bot_config.get('cf_archive_name'), |
| **kwargs |
| ) |
| |
| def download_build(self, name_suffix='', archive=None): |
| self.m.file.rmtree('build directory' + name_suffix, self.build_output_dir) |
| self.m.archive.download_and_unzip_build( |
| 'extract build' + name_suffix, |
| self.m.chromium.c.build_config_fs, |
| archive or self.m.properties.get('archive'), |
| src_dir=self.checkout_root.join('v8')) |
| |
| def download_isolated_json(self, revision): |
| archive = 'gs://' + self.isolated_archive_path + '/%s.json' % revision |
| self.m.gsutil.download_url( |
| archive, |
| self.m.json.output(), |
| name='download isolated json', |
| step_test_data=lambda: self.m.json.test_api.output( |
| {'bot_default': '[dummy hash for bisection]'}), |
| ) |
| step_result = self.m.step.active_result |
| self._isolated_tests_override = step_result.json.output |
| |
| @property |
| def build_output_dir(self): |
| """Absolute path to the build product based on the 'checkout' path.""" |
| return self.m.chromium.c.build_dir.join(self.m.chromium.c.build_config_fs) |
| |
| @property |
| def generate_gcov_coverage(self): |
| return bool(self.bot_config.get('gcov_coverage_folder')) |
| |
| def init_gcov_coverage(self): |
| """Delete all gcov counter files.""" |
| self.m.docker.login() |
| self.m.docker.run( |
| LCOV_IMAGE, |
| 'lcov zero counters', |
| ['lcov', '--directory', self.build_output_dir, '--zerocounters'], |
| dir_mapping=[(self.build_output_dir, self.build_output_dir)], |
| ) |
| |
| def upload_gcov_coverage_report(self): |
| """Capture coverage data and upload a report.""" |
| coverage_dir = self.m.path.mkdtemp('gcov_coverage') |
| report_dir = self.m.path.mkdtemp('gcov_coverage_html') |
| output_file = self.m.path.join(coverage_dir, 'app.info') |
| |
| dir_mapping = [ |
| # We need to map entire checkout, since some of the files generated by |
| # lcov inside the build output dir contain relative paths to files in |
| # the checkout and without this map, genhtml step below will fail to |
| # find them. |
| (self.m.path['checkout'], self.m.path['checkout']), |
| (coverage_dir, coverage_dir), |
| (report_dir, report_dir), |
| ] |
| |
| # Capture data from gcda and gcno files. |
| self.m.docker.run( |
| LCOV_IMAGE, |
| 'lcov capture', |
| [ |
| 'lcov', |
| '--directory', self.build_output_dir, |
| '--capture', |
| '--output-file', output_file, |
| ], |
| dir_mapping, |
| ) |
| |
| # Remove unwanted data. |
| self.m.docker.run( |
| LCOV_IMAGE, |
| 'lcov remove', |
| [ |
| 'lcov', |
| '--directory', self.build_output_dir, |
| '--remove', output_file, |
| 'third_party/*', |
| 'testing/gtest/*', |
| 'testing/gmock/*', |
| '/usr/include/*', |
| '--output-file', output_file, |
| ], |
| dir_mapping, |
| ) |
| |
| # Generate html report into a temp folder. |
| self.m.docker.run( |
| LCOV_IMAGE, |
| 'genhtml', |
| [ |
| 'genhtml', |
| '--output-directory', report_dir, |
| output_file, |
| ], |
| dir_mapping, |
| ) |
| |
| # Upload report to google storage. |
| dest = '%s/%s' % (self.bot_config['gcov_coverage_folder'], self.revision) |
| if self.m.runtime.is_experimental: |
| dest = 'experimental/%s' % dest |
| result = self.m.gsutil( |
| [ |
| '-m', 'cp', '-a', 'public-read', '-R', report_dir, |
| 'gs://chromium-v8/%s' % dest, |
| ], |
| 'coverage report', |
| ) |
| result.presentation.links['report'] = ( |
| 'https://storage.googleapis.com/chromium-v8/%s/index.html' % dest) |
| |
| @property |
| def generate_sanitizer_coverage(self): |
| return bool(self.bot_config.get('sanitizer_coverage_folder')) |
| |
| def create_coverage_context(self): |
| if self.generate_sanitizer_coverage: |
| return testing.SanitizerCoverageContext(self.m) |
| else: |
| return testing.NULL_COVERAGE |
| |
| def create_test(self, test): |
| """Wrapper that allows to shortcut common tests with their names. |
| |
| Returns: A runnable test instance. |
| """ |
| return testing.create_test(test, self.m) |
| |
| def create_tests(self): |
| return [self.create_test(t) for t in self.bot_config.get('tests', [])] |
| |
| @property |
| def is_pure_swarming_tester(self): |
| return (self.bot_type == 'tester' and |
| self.bot_config.get('enable_swarming', True)) |
| |
| def runtests(self, tests): |
| if self.extra_flags: |
| result = self.m.step('Customized run with extra flags', cmd=None) |
| result.presentation.step_text += ' '.join(self.extra_flags) |
| assert all(re.match(r'[\w\-]*', x) for x in self.extra_flags), ( |
| 'no special characters allowed in extra flags') |
| |
| start_time_sec = self.m.time.time() |
| test_results = testing.TestResults.empty() |
| |
| # Apply test filter. |
| # TODO(machenbach): Track also the number of tests that ran and throw an |
| # error if the overall number of tests from all steps was zero. |
| tests = [t for t in tests if t.apply_filter()] |
| |
| swarming_tests = [t for t in tests if t.uses_swarming] |
| non_swarming_tests = [t for t in tests if not t.uses_swarming] |
| failed_tests = [] |
| |
| # Creates a coverage context if coverage is tracked. Null object otherwise. |
| coverage_context = self.create_coverage_context() |
| |
| # Make sure swarming triggers come first. |
| # TODO(machenbach): Port this for rerun for bisection. |
| for t in swarming_tests + non_swarming_tests: |
| try: |
| t.pre_run(coverage_context=coverage_context) |
| except self.m.step.InfraFailure: # pragma: no cover |
| raise |
| except self.m.step.StepFailure: # pragma: no cover |
| failed_tests.append(t) |
| |
| # Setup initial zero coverage after all swarming jobs are triggered. |
| coverage_context.setup() |
| |
| # Make sure non-swarming tests are run before swarming results are |
| # collected. |
| for t in non_swarming_tests + swarming_tests: |
| try: |
| test_results += t.run(coverage_context=coverage_context) |
| except self.m.step.InfraFailure: # pragma: no cover |
| raise |
| except self.m.step.StepFailure: # pragma: no cover |
| failed_tests.append(t) |
| |
| # Upload accumulated coverage data. |
| coverage_context.maybe_upload() |
| |
| if failed_tests: |
| failed_tests_names = [t.name for t in failed_tests] |
| raise self.m.step.StepFailure( |
| '%d tests failed: %r' % (len(failed_tests), failed_tests_names)) |
| self.test_duration_sec = self.m.time.time() - start_time_sec |
| return test_results |
| |
| def maybe_bisect(self, test_results): |
| """Build-local bisection for one failure.""" |
| # Don't activate for branch or fyi bots. |
| if self.m.properties['mastername'] not in ['client.v8', 'client.v8.ports']: |
| return |
| |
| if self.bot_config.get('disable_auto_bisect'): # pragma: no cover |
| return |
| |
| # Only bisect over failures not flakes. Rerun only the fastest test. |
| try: |
| failure = min(test_results.failures, key=lambda r: r.duration) |
| except ValueError: |
| return |
| |
| # Only bisect if the fastest failure is significantly faster than the |
| # ongoing build's total. |
| duration_factor = self.m.properties.get( |
| 'bisect_duration_factor', BISECT_DURATION_FACTOR) |
| if (failure.duration * duration_factor > self.test_duration_sec): |
| step_result = self.m.step( |
| 'Bisection disabled - test too slow', cmd=None) |
| return |
| |
| # Don't retry failures during bisection. |
| self.rerun_failures_count = 0 |
| |
| # Suppress using shards to be able to rerun single tests. |
| self.c.testing.may_shard = False |
| |
| # Only rebuild the target of the test to retry. |
| targets = [failure.failure_dict.get('target_name', 'All')] |
| |
| test = self.create_test(failure.test_step_config) |
| def test_func(revision): |
| return test.rerun(failure_dict=failure.failure_dict) |
| |
| def is_bad(revision): |
| with self.m.step.nest('Bisect ' + revision[:8]): |
| if not self.is_pure_swarming_tester: |
| self.checkout(revision, update_presentation=False) |
| if self.bot_type == 'builder_tester': |
| self.runhooks() |
| self.compile(targets=targets) |
| elif self.bot_type == 'tester': |
| if test.uses_swarming: |
| self.download_isolated_json(revision) |
| else: # pragma: no cover |
| raise self.m.step.InfraFailure('Swarming required for bisect.') |
| else: # pragma: no cover |
| raise self.m.step.InfraFailure( |
| 'Bot type %s not supported.' % self.bot_type) |
| result = test_func(revision) |
| if result.infra_failures: # pragma: no cover |
| raise self.m.step.InfraFailure( |
| 'Cannot continue bisection due to infra failures.') |
| return result.failures |
| |
| with self.m.step.nest('Bisect'): |
| # Setup bisection range ("from" exclusive). |
| latest_previous, bisect_range = self.get_change_range() |
| if len(bisect_range) <= 1: |
| self.m.step('disabled - less than two changes', cmd=None) |
| return |
| |
| if self.bot_type == 'tester': |
| # Filter the bisect range to the revisions for which isolate hashes or |
| # archived builds are available, depending on whether swarming is used |
| # or not. |
| available_bisect_range = self.get_available_range( |
| bisect_range, test.uses_swarming) |
| else: |
| available_bisect_range = bisect_range |
| |
| if is_bad(latest_previous): |
| # If latest_previous is already "bad", the test failed before the current |
| # build's change range, i.e. it is a recurring failure. |
| # TODO: Try to be smarter here, fetch the build data from the previous |
| # one or two builds and check if the failure happened in revision |
| # latest_previous. Otherwise, the cost of calling is_bad is as much as |
| # one bisect step. |
| step_result = self.m.step( |
| 'Bisection disabled - recurring failure', cmd=None) |
| step_result.presentation.status = self.m.step.WARNING |
| return |
| |
| # Log available revisions to ease debugging. |
| self.log_available_range(available_bisect_range) |
| |
| culprit = bisection.keyed_bisect(available_bisect_range, is_bad) |
| culprit_range = self.calc_missing_values_in_sequence( |
| bisect_range, |
| available_bisect_range, |
| culprit, |
| ) |
| self.report_culprits(culprit_range) |
| |
| @staticmethod |
| def format_duration(duration_in_seconds): |
| duration = datetime.timedelta(seconds=duration_in_seconds) |
| time = (datetime.datetime.min + duration).time() |
| return time.strftime('%M:%S:') + '%03i' % int(time.microsecond / 1000) |
| |
| def _command_results_text(self, results, flaky): |
| """Returns log lines for all results of a unique command.""" |
| assert results |
| lines = [] |
| |
| # Add common description for multiple runs. |
| flaky_suffix = ' (flaky in a repeated run)' if flaky else '' |
| lines.append('Test: %s%s' % (results[0]['name'], flaky_suffix)) |
| lines.append('Flags: %s' % ' '.join(results[0]['flags'])) |
| lines.append('Command: %s' % results[0]['command']) |
| lines.append('Variant: %s' % results[0]['variant']) |
| lines.append('') |
| lines.append('Build environment:') |
| build_environment = self.build_environment |
| if build_environment is None: |
| lines.append( |
| 'Not available. Please look up the builder\'s configuration.') |
| else: |
| for key in sorted(build_environment): |
| lines.append(' %s: %s' % (key, build_environment[key])) |
| lines.append('') |
| |
| # Add results for each run of a command. |
| for result in sorted(results, key=lambda r: int(r['run'])): |
| lines.append('Run #%d' % int(result['run'])) |
| lines.append('Exit code: %s' % result['exit_code']) |
| lines.append('Result: %s' % result['result']) |
| if result.get('expected'): |
| lines.append('Expected outcomes: %s' % ", ".join(result['expected'])) |
| lines.append('Duration: %s' % V8Api.format_duration(result['duration'])) |
| lines.append('') |
| if result['stdout']: |
| lines.append('Stdout:') |
| lines.extend(result['stdout'].splitlines()) |
| lines.append('') |
| if result['stderr']: |
| lines.append('Stderr:') |
| lines.extend(result['stderr'].splitlines()) |
| lines.append('') |
| return lines |
| |
| def _duration_results_text(self, test): |
| return [ |
| 'Test: %s' % test['name'], |
| 'Flags: %s' % ' '.join(test['flags']), |
| 'Command: %s' % test['command'], |
| 'Duration: %s' % V8Api.format_duration(test['duration']), |
| ] |
| |
| def _update_durations(self, output, presentation): |
| # Slowest tests duration summary. |
| lines = [] |
| for test in output['slowest_tests']: |
| suffix = '' |
| if test.get('marked_slow') is False: |
| suffix = ' *' |
| lines.append( |
| '%s %s%s' % (V8Api.format_duration(test['duration']), |
| test['name'], suffix)) |
| |
| # Slowest tests duration details. |
| lines.extend(['', 'Details:', '']) |
| for test in output['slowest_tests']: |
| lines.extend(self._duration_results_text(test)) |
| presentation.logs['durations'] = lines |
| |
| def _get_failure_logs(self, output, failure_factory): |
| def all_same(items): |
| return all(x == items[0] for x in items) |
| |
| if not output['results']: |
| return {}, [], {}, [] |
| |
| unique_results = {} |
| for result in output['results']: |
| # Use test base name as UI label (without suite and directory names). |
| label = result['name'].split('/')[-1] |
| # Truncate the label if it is still too long. |
| if len(label) > MAX_LABEL_SIZE: |
| label = label[:MAX_LABEL_SIZE - 2] + '..' |
| # Group tests with the same label (usually the same test that ran under |
| # different configurations). |
| unique_results.setdefault(label, []).append(result) |
| |
| failure_log = {} |
| flake_log = {} |
| failures = [] |
| flakes = [] |
| for label in sorted(unique_results.keys()[:MAX_FAILURE_LOGS]): |
| failure_lines = [] |
| flake_lines = [] |
| |
| # Group results by command. The same command might have run multiple |
| # times to detect flakes. |
| results_per_command = {} |
| for result in unique_results[label]: |
| results_per_command.setdefault(result['command'], []).append(result) |
| |
| for command in results_per_command: |
| # Determine flakiness. A test is flaky if not all results from a unique |
| # command are the same (e.g. all 'FAIL'). |
| if all_same(map(lambda x: x['result'], results_per_command[command])): |
| # This is a failure. Only add the data of the first run to the final |
| # test results, as rerun data is not important for bisection. |
| failure = results_per_command[command][0] |
| failures.append(failure_factory(failure, failure['duration'])) |
| failure_lines += self._command_results_text( |
| results_per_command[command], False) |
| else: |
| # This is a flake. Only add the data of the first run to the final |
| # test results, as rerun data is not important for bisection. |
| flake = results_per_command[command][0] |
| flakes.append(failure_factory(flake, flake['duration'])) |
| flake_lines += self._command_results_text( |
| results_per_command[command], True) |
| |
| if failure_lines: |
| failure_log[label] = failure_lines |
| if flake_lines: |
| flake_log[label] = flake_lines |
| |
| return failure_log, failures, flake_log, flakes |
| |
| def _update_failure_presentation(self, log, failures, presentation): |
| for label in sorted(log): |
| presentation.logs[label] = log[label] |
| |
| if failures: |
| # Number of failures. |
| presentation.step_text += ('failures: %d<br/>' % len(failures)) |
| |
| @property |
| def extra_flags(self): |
| extra_flags = self.m.properties.get('extra_flags', '') |
| if isinstance(extra_flags, basestring): |
| extra_flags = extra_flags.split() |
| assert isinstance(extra_flags, list) or isinstance(extra_flags, tuple) |
| return list(extra_flags) |
| |
| def _with_extra_flags(self, args): |
| """Returns: the arguments with additional extra flags inserted. |
| |
| Extends a possibly existing extra flags option. |
| """ |
| if not self.extra_flags: |
| return args |
| |
| options, args = TEST_RUNNER_PARSER.parse_known_args(args) |
| |
| if options.extra_flags: |
| new_flags = [options.extra_flags] + self.extra_flags |
| else: |
| new_flags = self.extra_flags |
| |
| args.extend(['--extra-flags', ' '.join(new_flags)]) |
| return args |
| |
| @property |
| def test_filter(self): |
| return [f for f in self.m.properties.get('testfilter', []) |
| if f != 'defaulttests'] |
| |
| def _applied_test_filter(self, test): |
| """Returns: the list of test filters that match a test configuration.""" |
| # V8 test filters always include the full suite name, followed |
| # by more specific paths and possibly ending with a glob, e.g.: |
| # 'mjsunit/regression/prefix*'. |
| return [f for f in self.test_filter |
| for t in test.get('suite_mapping', test['tests']) |
| if f.startswith(t)] |
| |
| def _setup_test_runner(self, test, applied_test_filter, test_step_config): |
| env = {} |
| full_args = [ |
| '--progress=verbose', |
| '--mode', self.m.chromium.c.build_config_fs, |
| '--outdir', self.m.path.split(self.m.chromium.c.build_dir)[-1], |
| '--buildbot', |
| '--timeout=200', |
| ] |
| |
| # Add optional non-standard root directory for test suites. |
| if test.get('test_root'): |
| full_args += ['--test-root', test['test_root']] |
| |
| # On reruns, there's a fixed random seed set in the test configuration. |
| if ('--random-seed' not in test.get('test_args', []) and |
| test.get('use_random_seed', True)): |
| full_args.append('--random-seed=%d' % self.testing_random_seed()) |
| |
| # Either run tests as specified by the filter (trybots only) or as |
| # specified by the test configuration. |
| if applied_test_filter: |
| full_args += applied_test_filter |
| else: |
| full_args += list(test.get('tests', [])) |
| |
| # Add test-specific test arguments. |
| full_args += test.get('test_args', []) |
| |
| # Add builder-specific test arguments. |
| full_args += self.c.testing.test_args |
| |
| # Add builder-, test- and step-specific variants. |
| full_args += testing.test_args_from_variants( |
| self.bot_config.get('variants'), |
| test.get('variants'), |
| test_step_config.variants, |
| ) |
| |
| # Add step-specific test arguments. |
| full_args += test_step_config.test_args |
| |
| full_args = self._with_extra_flags(full_args) |
| |
| full_args += [ |
| '--rerun-failures-count=%d' % self.rerun_failures_count, |
| ] |
| |
| # TODO(machenbach): This is temporary code for rolling out the new test |
| # runner. It should be removed after the roll-out. We skip the branches |
| # waterfall, as it runs older versions of the V8 side. |
| if self.m.properties['mastername'] != 'client.v8.branches': |
| full_args += [ |
| '--mastername', self.m.properties['mastername'], |
| '--buildername', self.m.properties['buildername'], |
| ] |
| |
| return full_args, env |
| |
| @staticmethod |
| def _copy_property(src, dest, key): |
| if key in src: |
| dest[key] = src[key] |
| |
| def maybe_trigger(self, test_spec=EmptyTestSpec, **additional_properties): |
| triggers = self.bot_config.get('triggers', []) |
| triggers_proxy = self.bot_config.get('triggers_proxy', False) |
| triggered_build_ids = [] |
| if triggers or triggers_proxy: |
| # Careful! Before adding new properties, note the following: |
| # Triggered bots on CQ will either need new properties to be explicitly |
| # whitelisted or their name should be prefixed with 'parent_'. |
| properties = { |
| 'parent_got_revision': self.revision, |
| 'parent_buildername': self.m.properties['buildername'], |
| 'parent_build_config': self.m.chromium.c.BUILD_CONFIG, |
| } |
| if self.revision_cp: |
| properties['parent_got_revision_cp'] = self.revision_cp |
| if self.m.tryserver.is_tryserver: |
| properties.update( |
| category=self.m.properties.get('category', 'manual_ts'), |
| reason=str(self.m.properties.get('reason', 'ManualTS')), |
| # On tryservers, set revision to the same as on the current bot, |
| # as CQ expects builders and testers to match the revision field. |
| revision=str(self.m.properties.get('revision', 'HEAD')), |
| ) |
| for p in ['issue', 'master', 'patch_gerrit_url', 'patch_git_url', |
| 'patch_issue', 'patch_project', 'patch_ref', |
| 'patch_repository_url', 'patch_set', 'patch_storage', |
| 'patchset', 'requester', 'rietveld']: |
| try: |
| properties[p] = str(self.m.properties[p]) |
| except KeyError: |
| pass |
| else: |
| # On non-tryservers, we can set the revision to whatever the |
| # triggering builder checked out. |
| properties['revision'] = self.revision |
| |
| if self.m.properties.get('testfilter'): |
| properties.update(testfilter=list(self.m.properties['testfilter'])) |
| self._copy_property(self.m.properties, properties, 'extra_flags') |
| |
| # TODO(machenbach): Also set meaningful buildbucket tags of triggering |
| # parent. |
| |
| # Pass build environment to testers if it doesn't exceed buildbot's |
| # limits. |
| # TODO(machenbach): Remove the check in the after-buildbot age. |
| if len(self.m.json.dumps(self.build_environment)) < 1024: |
| properties['parent_build_environment'] = self.build_environment |
| |
| swarm_hashes = self.m.isolate.isolated_tests |
| if swarm_hashes: |
| properties['swarm_hashes'] = swarm_hashes |
| properties.update(**additional_properties) |
| |
| if self.m.tryserver.is_tryserver: |
| if triggers: |
| trigger_props = {} |
| self._copy_property(self.m.properties, trigger_props, 'git_revision') |
| self._copy_property(self.m.properties, trigger_props, 'revision') |
| trigger_props.update(properties) |
| try: |
| bucket_name = self.m.buildbucket.properties['build']['bucket'] |
| except (TypeError, KeyError) as e: |
| bucket_name = 'master.%s' % self.m.properties['mastername'] |
| # Generate a list of fake changes from the blamelist property to have |
| # correct blamelist displayed on the child build. Unfortunately, this |
| # only copies author names, but additional details about the list of |
| # changes associated with the build are currently not accessible from |
| # the recipe code. |
| step_result = self.buildbucket_trigger( |
| bucket_name, self.get_changes(), |
| [{ |
| 'builder_name': builder_name, |
| 'properties': dict( |
| trigger_props, |
| **test_spec.as_properties_dict(builder_name) |
| ), |
| } for builder_name in triggers], |
| # Tryserver uses custom buildset that is set by the buildbucket |
| # module itself. |
| no_buildset=True, |
| ) |
| triggered_build_ids.extend( |
| build['build']['id'] for build in step_result.stdout['results']) |
| else: |
| ci_properties = dict(properties) |
| if self.should_upload_build: |
| ci_properties['archive'] = self._get_default_archive() |
| if self.m.runtime.is_luci: |
| self.m.scheduler.emit_triggers( |
| [( |
| self.m.scheduler.BuildbucketTrigger( |
| properties=dict( |
| ci_properties, |
| **test_spec.as_properties_dict(builder_name) |
| ), |
| tags={ |
| 'buildset': 'commit/gitiles/chromium.googlesource.com/v8/' |
| 'v8/+/%s' % ci_properties['revision'] |
| } |
| ), 'v8', [builder_name], |
| ) for builder_name in triggers], |
| step_name='trigger' |
| ) |
| else: |
| self.m.trigger(*[{ |
| 'builder_name': builder_name, |
| # Attach additional builder-specific test-spec properties. |
| 'properties': dict( |
| ci_properties, |
| **test_spec.as_properties_dict(builder_name) |
| ), |
| } for builder_name in triggers]) |
| |
| if triggers_proxy and not self.m.runtime.is_experimental: |
| proxy_properties = {'archive': self._get_default_archive()} |
| proxy_properties.update(properties) |
| self.buildbucket_trigger( |
| 'master.internal.client.v8', self.get_changes(), |
| [{ |
| 'properties': proxy_properties, |
| 'builder_name': 'v8_trigger_proxy' |
| }] |
| ) |
| |
| if triggered_build_ids: |
| output_properties = self.m.step.active_result.presentation.properties |
| output_properties['triggered_build_ids'] = triggered_build_ids |
| |
| def get_changes(self): |
| # TODO(sergiyb): Remove this after migrating all builders to LUCI as |
| # there the revision from the buildset will be used instead. |
| blamelist = self.m.properties.get('blamelist', ['fake-author']) |
| return [{'author': email} for email in blamelist] |
| |
| def buildbucket_trigger(self, bucket, changes, requests, step_name='trigger', |
| service_account='v8-bot', no_buildset=False): |
| """Triggers builds via buildbucket. |
| |
| Args: |
| bucket: Name of the bucket to add builds to. |
| changes: List of changes to be associated with the scheduled build. Each |
| entry is a dictionary like this: {'author': ..., 'revision': ...}. The |
| revision is optional and will be extracted from build propeties if not |
| provided. The author is an arbitrary string or an email address. |
| requests: List of requests, where each request is a dictionary like this: |
| {'builder_name': ..., 'properties': {'revision': ..., ...}}. Note that |
| builder_name and revision are mandatory, whereas additional properties |
| are optional. |
| step_name: Name of the triggering step that appear on the build. |
| service_account: Puppet service account to be used for authentication to |
| buildbucket. |
| no_buildset: Disable setting custom buildset. Useful when one needs to |
| rely on the built-in buildset set by the buildbucket module, e.g. on |
| tryserver. |
| """ |
| # TODO(sergiyb): Remove this line after migrating all builders to swarming. |
| # There an implicit task account (specified in the cr-buildbucket.cfg) will |
| # be used instead. |
| if not self.m.runtime.is_luci: |
| self.m.buildbucket.use_service_account_key( |
| self.m.puppet_service_account.get_key_path(service_account)) |
| |
| step_result = self.m.buildbucket.put( |
| [{ |
| 'bucket': bucket, |
| 'tags': {} if no_buildset else { |
| 'buildset': 'commit/gitiles/chromium.googlesource.com/v8/v8/+/%s' % |
| request['properties']['revision'] |
| }, |
| 'parameters': { |
| 'builder_name': request['builder_name'], |
| 'properties': request['properties'], |
| # This is required by Buildbot to correctly set 'revision' and |
| # 'repository' properties, which are used by Milo and the recipe. |
| # TODO(sergiyb): Remove this after migrating to LUCI/Swarming. |
| 'changes': [{ |
| 'author': {'email': change['author']}, |
| 'revision': change.get( |
| 'revision', request['properties']['revision']), |
| 'repo_url': 'https://chromium.googlesource.com/v8/v8' |
| } for change in changes], |
| }, |
| } for request in requests], |
| name=step_name, |
| step_test_data=lambda: ( |
| self.m.v8.test_api.buildbucket_test_data(len(requests))), |
| ) |
| |
| if 'error' in step_result.stdout: |
| step_result.presentation.status = self.m.step.FAILURE |
| |
| return step_result |
| |
| def get_change_range(self): |
| if self.m.properties.get('override_changes'): |
| # This can be used for manual testing or on a staging builder that |
| # simulates a change range. |
| changes = self.m.properties['override_changes'] |
| step_result = self.m.step('Override changes', cmd=None) |
| step_result.presentation.logs['changes'] = self.m.json.dumps( |
| changes, indent=2).splitlines() |
| else: |
| # TODO(sergiyb): Migrate to using new Milo API that allows to specify |
| # Buildbucket ID to retrieve build info. This will allow to deprecate |
| # build numbers for builders using V8 recipe. |
| url = '%s/p/%s/builders/%s/builds/%s?json=1' % ( |
| CBE_URL, |
| self.m.properties['mastername'], |
| urllib.quote(self.m.properties['buildername']), |
| str(self.m.properties['buildnumber']), |
| ) |
| change_json = self.m.url.get_json( |
| url, |
| step_name='Fetch changes', |
| default_test_data=self.test_api.example_buildbot_changes(), |
| ).output |
| changes = change_json['sourceStamp']['changes'] |
| |
| assert changes |
| first_change = changes[0]['revision'] |
| last_change = changes[-1]['revision'] |
| |
| # Commits is a list of gitiles commit dicts in reverse chronological order. |
| commits, _ = self.m.gitiles.log( |
| url=V8_URL, |
| ref='%s~2..%s' % (first_change, last_change), |
| limit=100, |
| step_name='Get change range', |
| step_test_data=lambda: self.test_api.example_bisection_range() |
| ) |
| |
| # We get minimum two commits when the first and last commit are equal (i.e. |
| # there was only one commit C). Commits will contain the latest previous |
| # commit and C. |
| assert len(commits) > 1 |
| |
| return ( |
| # Latest previous. |
| commits[-1]['commit'], |
| # List of commits oldest -> newest, without the latest previous. |
| [commit['commit'] for commit in reversed(commits[:-1])], |
| ) |
| |
| def get_available_range(self, bisect_range, use_swarming=False): |
| assert self.bot_type == 'tester' |
| archive_url_pattern = 'gs://' + self.isolated_archive_path + '/%s.json' |
| # TODO(machenbach): Maybe parallelize this in a wrapper script. |
| args = ['ls'] |
| available_range = [] |
| # Check all builds except the last as we already know it is "bad". |
| for r in bisect_range[:-1]: |
| step_result = self.m.gsutil( |
| args + [archive_url_pattern % r], |
| name='check build %s' % r[:8], |
| # Allow failures, as the tool will formally fail for any absent file. |
| ok_ret='any', |
| stdout=self.m.raw_io.output_text(), |
| step_test_data=lambda: self.test_api.example_available_builds(r), |
| ) |
| if r in step_result.stdout.strip(): |
| available_range.append(r) |
| |
| # Always keep the latest revision in the range. The latest build is |
| # assumed to be "bad" and won't be tested again. |
| available_range.append(bisect_range[-1]) |
| return available_range |
| |
| def calc_missing_values_in_sequence( |
| self, sequence, subsequence, value): |
| """Calculate a list of missing values from a subsequence. |
| |
| Args: |
| sequence: The complete sequence including all values. |
| subsequence: A subsequence from the sequence above. |
| value: An element from subsequence. |
| Returns: A subsequence from sequence [a..b], where b is the value and |
| for all x in a..b-1 holds x not in subsequence. Also |
| a-1 is either in subsequence or value was the first |
| element in subsequence. |
| """ |
| from_index = 0 |
| to_index = sequence.index(value) + 1 |
| index_on_subsequence = subsequence.index(value) |
| if index_on_subsequence > 0: |
| # Value is not the first element in subsequence. |
| previous = subsequence[index_on_subsequence - 1] |
| from_index = sequence.index(previous) + 1 |
| return sequence[from_index:to_index] |
| |
| def log_available_range(self, available_bisect_range): |
| step_result = self.m.step('Available range', cmd=None) |
| for revision in available_bisect_range: |
| step_result.presentation.links[revision[:8]] = COMMIT_TEMPLATE % revision |
| |
| def report_culprits(self, culprit_range): |
| assert culprit_range |
| if len(culprit_range) > 1: |
| text = 'Suspecting multiple commits' |
| else: |
| text = 'Suspecting %s' % culprit_range[0][:8] |
| |
| step_result = self.m.step(text, cmd=None) |
| for culprit in culprit_range: |
| step_result.presentation.links[culprit[:8]] = COMMIT_TEMPLATE % culprit |
| |
| def read_version_file(self, ref, step_name_desc): |
| """Read and return the version-file content at a paricular ref.""" |
| with self.m.context(cwd=self.m.path['checkout']): |
| return self.m.git( |
| 'show', '%s:%s' % (ref, self.VERSION_FILE), |
| name='Check %s version file' % step_name_desc, |
| stdout=self.m.raw_io.output_text(), |
| ).stdout |
| |
| def read_version_from_ref(self, ref, step_name_desc): |
| """Read and return the version at a paricular ref.""" |
| return V8Api.version_from_file(self.read_version_file(ref, step_name_desc)) |
| |
| @staticmethod |
| def version_from_file(blob): |
| major = re.search(VERSION_LINE_RE % V8_MAJOR, blob, re.M).group(1) |
| minor = re.search(VERSION_LINE_RE % V8_MINOR, blob, re.M).group(1) |
| build = re.search(VERSION_LINE_RE % V8_BUILD, blob, re.M).group(1) |
| patch = re.search(VERSION_LINE_RE % V8_PATCH, blob, re.M).group(1) |
| return V8Version(major, minor, build, patch) |