blob: 86442a7670dccb32f4c0791d953ec1781d81a246 [file] [log] [blame]
# 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 base64
from collections import defaultdict
import contextlib
import datetime
import difflib
import functools
import random
import re
import urllib
# pylint: disable=relative-import
from builders import TestSpec
from recipe_engine.types import freeze
from recipe_engine import recipe_api
from . import bisection
from . import builders as v8_builders
from . import testing
MILO_HOST = 'luci-milo.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+$')
# Regular expressions for getting target bits from gn args.
TARGET_CPU_RE = re.compile(r'.*target_cpu\s+=\s+"([^"]*)".*')
# 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):
VERSION_FILE = 'include/v8-version.h'
EMPTY_TEST_SPEC = v8_builders.EmptyTestSpec
TEST_SPEC = v8_builders.TestSpec
def __init__(self, *args, **kwargs):
super(V8Api, self).__init__(*args, **kwargs)
self.test_configs = {}
self.bot_config = None
self.rerun_failures_count = None
self.test_duration_sec = None
self.isolated_tests = None
self.build_environment = None
self.checkout_root = None
self.revision = None
self.revision_cp = None
self.revision_number = None
def bot_config_by_buildername(
self, builders=None, use_goma=True):
default = {}
if not self.m.properties.get('parent_buildername'):
# Builders and builder_testers both build and need the following set of
# default chromium configs:
if use_goma:
default['chromium_apply_config'] = ['default_compiler', 'goma', 'mb']
else:
default['chromium_apply_config'] = ['default_compiler', 'mb']
return (builders or {}).get(self.m.buildbucket.builder_name, default)
def update_bot_config(self, bot_config, binary_size_tracking, build_config,
clusterfuzz_archive, coverage, enable_swarming,
target_arch, target_platform, track_build_dependencies,
triggers, triggers_proxy):
"""Update bot_config dict with src-side properties.
Args:
bot_config: The bot_config dict to update.
binary_size_tracking: Additional configurations to enable binary size
tracking.
build_config: Config value for BUILD_CONFIG in chromium recipe module.
clusterfuzz_archive: Additional configurations set for archiving builds to
GS buckets for clusterfuzz.
coverage: Optional coverage setting.
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.
track_build_dependencies: Weather to track and upload build-dependencies.
triggers: List of tester names to trigger on success.
triggers_proxy: Weather to trigger the internal trigger proxy.
Returns:
An updated copy of the bot_config dict.
"""
# TODO(machenbach): Turn the bot_config dict into a proper class.
# 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 coverage is not None:
bot_config['coverage'] = coverage
if enable_swarming is not None:
bot_config['enable_swarming'] = enable_swarming
if binary_size_tracking is not None:
bot_config['binary_size_tracking'] = binary_size_tracking
if clusterfuzz_archive is not None:
bot_config['clusterfuzz_archive'] = clusterfuzz_archive
if track_build_dependencies is not None:
bot_config['track_build_dependencies'] = track_build_dependencies
# 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'])))
bot_config['triggers_proxy'] = triggers_proxy
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(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('chromium_apply_config', []):
self.m.chromium.apply_config(c)
# On clusterfuzz builders use the default clusterfuzz gn target.
if self.bot_config.get('clusterfuzz_archive'):
self.m.chromium.apply_config('default_target_v8_clusterfuzz')
# 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')
# Apply additional configs for coverage builders.
if self.bot_config.get('coverage') == 'gcov':
self.bot_config['disable_auto_bisect'] = True
elif self.bot_config.get('coverage') == 'sanitizer':
self.m.gclient.apply_config('llvm_compiler_rt')
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
# Contains list of isolated tests, which can be either a list of tests from
# one or more steps uploading to isolate server, isolate hashes from the
# build part of the bisect step or a list of values from the swarm_hashes
# property (parsed by self.m.isolate.isolated_tests property getter invoked
# here, which returns a new dict each time, thus no need to copy it here).
self.isolated_tests = self.m.isolate.isolated_tests
# 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'
def set_gclient_custom_deps(self, custom_deps):
"""Configures additional gclient custom_deps to be synced."""
for name, path in (custom_deps or {}).iteritems():
self.m.gclient.c.solutions[0].custom_deps[name] = path
def set_chromium_configs(self, clobber, default_targets):
if clobber:
self.m.chromium.c.clobber_before_runhooks = clobber
if default_targets:
self.m.chromium.c.compile_py.default_targets = default_targets
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.buildbucket.gitiles_commit.id or 'HEAD'
solution = self.m.gclient.c.solutions[0]
branch = self.m.buildbucket.gitiles_commit.ref
if RELEASE_BRANCH_RE.match(branch):
revision = '%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.buildbucket.builder_name)
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']
self.parse_revision_props(
self.m.bot_update.last_returned_properties['got_revision'],
self.m.bot_update.last_returned_properties.get('got_revision_cp'))
return update_step
def parse_revision_props(self, got_revision, got_revision_cp=None):
"""Parses got_revision and got_revision_cp properties.
Sets self.revision, self.revision_cp and self.revision_number.
Normally this is called from self.checkout above, but it may also be useful
on bots where we do not have a checkout but have these properties (e.g. set
by the parent builder when triggering child) and need to parse them.
Args:
got_revision: Full git hash of the commit.
got_revision_cp: Value of the Cr-Commit-Position commit footer, e.g.
"refs/heads/master@{#12345}".
"""
self.revision = got_revision
self.revision_cp = got_revision_cp
# Note, a commit position might not be available on feature branches.
self.revision_number = None
if self.revision_cp:
self.revision_number = str(self.m.commit_position.parse_revision(
self.revision_cp))
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.tryserver.gerrit_change_fetch_ref,
limit=100,
step_name='Get patches',
step_test_data=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
self.m.swarming.task_output_stdout = 'all'
# TODO(tikuta): Remove this after the switch (crbug.com/894045).
self.m.swarming.use_go_client = True
if self.m.properties['mastername'] == 'tryserver.v8':
self.m.swarming.add_default_tag('purpose:pre-commit')
self.m.swarming.default_priority = 30
changes = self.m.buildbucket.build.input.gerrit_changes
assert len(changes) <= 1
if changes and changes[0].project:
self.m.swarming.add_default_tag('patch_project:%s' % changes[0].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')
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.buildbucket.builder_name] +
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')
@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 v8_builders.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.buildbucket.builder_name)
]
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: v8_builders.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 v8_builders.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
def isolate_tests(self, isolate_targets, out_dir=None):
"""Upload isolated tests to isolate server.
Args:
isolate_targets: Targets to isolate.
out_dir: Name of the build output directory, e.g. 'out-ref'. Defaults to
'out'. Note that it is not a path, but just the name of the directory.
"""
output_dir = self.m.chromium.output_dir
if out_dir:
output_dir = self.m.path['checkout'].join(
out_dir, self.m.chromium.c.build_config_fs)
# Special handling for 'perf' target, since perf tests are going to be
# executed on an internal swarming server and thus need to be uploaded to
# internal isolate server.
if 'perf' in isolate_targets:
isolate_targets.remove('perf')
self.m.isolate.isolate_server = 'https://chrome-isolated.appspot.com'
self.m.isolate.isolate_tests(
output_dir,
targets=['perf'],
verbose=True,
swarm_hashes_property_name=None,
step_name='isolate tests (perf)',
)
self.isolated_tests.update(self.m.isolate.isolated_tests)
self.m.isolate.isolate_server = 'https://isolateserver.appspot.com'
if isolate_targets:
self.m.isolate.isolate_tests(
output_dir,
targets=isolate_targets,
verbose=True,
swarm_hashes_property_name=None,
)
self.isolated_tests.update(self.m.isolate.isolated_tests)
if self.isolated_tests:
self.upload_isolated_json()
def _update_build_environment(self, gn_args):
"""Sets the build_environment property based on gn arguments."""
self.build_environment = {}
# Space-join all gn args, except goma_dir.
self.build_environment['gn_args'] = ' '.join(
l for l in gn_args.splitlines()
if not l.startswith('goma_dir'))
@property
def target_bits(self):
"""Returns target bits (as int) inferred from gn arguments from MB."""
match = TARGET_CPU_RE.match(self.build_environment.get('gn_args', ''))
if match:
return 64 if '64' in match.group(1) else 32
# If target_cpu is not set, gn defaults to 64 bits.
return 64 # pragma: no cover
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', '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, binary, category):
"""Track and upload binary size of configured binaries.
Args:
binary: Binary name joined to the build output folder.
category: ChromePerf category for qualifying the graph names, e.g.
linux32 or linux64.
"""
size = self.m.file.filesizes(
'Check binary size', [self.build_output_dir.join(binary)])[0]
point_defaults = {
'units': 'bytes',
'supplemental_columns': {
'a_default_rev': 'r_v8_git',
'r_v8_git': self.revision,
},
}
point = self.m.perf_dashboard.get_skeleton_point(
'/'.join(['v8.infra', 'binary_size', binary]),
self.revision_number,
str(size),
bot=category,
)
point.update(point_defaults)
self.m.perf_dashboard.add_point([point])
def compile(
self, test_spec=v8_builders.EmptyTestSpec, mb_config_path=None,
out_dir=None, **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.
mb_config_path: Path to the MB config file. Defaults to
infra/mb/mb_config.py in the checkout.
out_dir: Name of the build output directory, e.g. 'out-ref'. Defaults to
'out'. Note that it is not a path, but just the name of the directory.
"""
with self.m.osx_sdk('mac'): # this is no-op on non-Mac hosts
use_goma = (self.m.chromium.c.compile_py.compiler and
'goma' in self.m.chromium.c.compile_py.compiler)
# Calculate targets to isolate from V8-side test specification. The
# test_spec contains extra TestStepConfig objects for the current builder
# and all its triggered builders.
isolate_targets = self.isolate_targets_from_tests(
test_spec.get_all_test_names())
# Add the performance-tests isolate everywhere, where the perf-bot proxy
# is triggered.
if self.bot_config.get('triggers_proxy', False):
isolate_targets = isolate_targets + ['perf']
# Sort and dedupe.
isolate_targets = sorted(list(set(isolate_targets)))
build_dir = None
if out_dir: # pragma: no cover
build_dir = '//%s/%s' % (out_dir, self.m.chromium.c.build_config_fs)
if self.m.chromium.c.project_generator.tool == 'mb':
mb_config_rel_path = self.m.properties.get(
'mb_config_path', 'infra/mb/mb_config.pyl')
gn_args = self.m.chromium.mb_gen(
self.m.properties['mastername'],
self.m.buildbucket.builder_name,
use_goma=use_goma,
mb_config_path=(
mb_config_path or
self.m.path['checkout'].join(*mb_config_rel_path.split('/'))),
isolated_targets=isolate_targets,
build_dir=build_dir,
gn_args_location=self.m.gn.LOGS)
# Update the build environment dictionary, which is printed to the
# user on test failures for easier build reproduction.
self._update_build_environment(gn_args)
# 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, build_dir=build_dir)
if use_goma:
kwargs['use_goma_module'] = True
self.m.chromium.compile(out_dir=out_dir, **kwargs)
self.isolate_tests(isolate_targets, out_dir=out_dir)
@property
def should_collect_post_compile_metrics(self):
return (
self.m.v8.bot_config.get('track_build_dependencies') or
self.m.v8.bot_config.get('binary_size_tracking'))
def collect_post_compile_metrics(self):
with self.m.osx_sdk('mac'): # this is no-op on non-Mac hosts
if self.bot_config.get('track_build_dependencies', False):
with self.m.context(env_prefixes={'PATH': [self.depot_tools_path]}):
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=self.test_api.example_build_dependencies,
ok_ret='any',
venv=True,
).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['binary'],
tracking_config['category'],
)
@property
def depot_tools_path(self):
"""Returns path to depot_tools pinned in the V8 checkout."""
assert 'checkout' in self.m.path, (
"Pinned depot_tools is not available before checkout has been created")
return self.m.path['checkout'].join('third_party', 'depot_tools')
def _get_default_archive(self):
return 'gs://chromium-v8/archives/%s/%s' % (
self.m.properties['mastername'],
self.m.buildbucket.builder_name,
)
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.buildbucket.builder_name)
return 'chromium-v8/isolated/%s/%s' % (
self.m.properties['mastername'], buildername)
def upload_isolated_json(self):
self.m.gsutil.upload(
self.m.json.input(self.isolated_tests),
self.isolated_archive_path,
'%s.json' % self.revision,
args=['-a', 'public-read'],
)
def maybe_create_clusterfuzz_archive(self, update_step):
clusterfuzz_archive = self.bot_config.get('clusterfuzz_archive')
if clusterfuzz_archive:
kwargs = {}
if clusterfuzz_archive.get('bitness'):
kwargs['use_legacy'] = False
kwargs['bitness'] = clusterfuzz_archive['bitness']
self.m.archive.clusterfuzz_archive(
revision_dir='v8',
build_dir=self.build_output_dir,
update_properties=update_step.presentation.properties,
gs_bucket=clusterfuzz_archive.get('bucket'),
gs_acl='public-read',
archive_prefix=clusterfuzz_archive.get('name'),
**kwargs
)
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 = 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 self.bot_config.get('coverage') == 'gcov'
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%d_gcov_rel/%s' % (
self.m.platform.name, self.target_bits, self.revision)
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 self.bot_config.get('coverage') == 'sanitizer'
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))
@contextlib.contextmanager
def maybe_nest(self, condition, parent_step_name):
if not condition:
yield
else:
with self.m.step.nest(parent_step_name):
yield
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()
with self.maybe_nest(swarming_tests, 'trigger tests'):
# 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(_):
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)
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 _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):
if not output['results']:
return {}, [], {}, []
unique_results = defaultdict(list)
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[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 = defaultdict(list)
for result in unique_results[label]:
results_per_command[result['command']].append(result)
for results in results_per_command.values():
# Determine flakiness.
failure = failure_factory(results)
if failure.is_flaky:
# This is a flake.
flakes.append(failure)
flake_lines += failure.log_lines()
else:
# This is a failure.
failures.append(failure)
failure_lines += failure.log_lines()
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.buildbucket.builder_name,
]
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=v8_builders.EmptyTestSpec,
**additional_properties):
triggers = self.bot_config.get('triggers', [])
triggers_proxy = self.bot_config.get('triggers_proxy', False)
if not triggers and not triggers_proxy:
return
properties = {
'parent_got_revision': self.revision,
'parent_buildername': self.m.buildbucket.builder_name,
'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 it
# is used to generate buildset tag in buildbucket_trigger below.
revision=self.m.buildbucket.gitiles_commit.id or 'HEAD',
patch_gerrit_url='https://%s' % self.m.tryserver.gerrit_change.host,
patch_issue=self.m.tryserver.gerrit_change.change,
patch_project=self.m.tryserver.gerrit_change.project,
patch_set=self.m.tryserver.gerrit_change.patchset,
patch_storage='gerrit',
)
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.isolated_tests
if swarm_hashes:
properties['swarm_hashes'] = swarm_hashes
properties.update(**additional_properties)
triggered_build_ids = []
if triggers:
if self.m.tryserver.is_tryserver:
trigger_props = {}
self._copy_property(self.m.properties, trigger_props, 'revision')
trigger_props.update(properties)
bucket_name = (
self.m.buildbucket.bucket_v1 or
'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.
add_buildset_tag=False,
)
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:
proxy_properties = {'archive': self._get_default_archive()}
proxy_properties.update(properties)
self.buildbucket_trigger(
'luci.v8-internal.ci', self.get_changes(),
[{
'properties': proxy_properties,
'builder_name': 'v8_trigger_proxy'
}],
step_name='trigger_internal'
)
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', add_buildset_tag=True):
"""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.
add_buildset_tag: Adds a buildset tag based on revision property.
"""
# 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))
requests = [{
'bucket': bucket,
'tags': request.get('tags', {}),
'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]
if add_buildset_tag:
for request in requests:
request['tags']['buildset'] = (
'commit/gitiles/chromium.googlesource.com/v8/v8/+/%s' %
request['parameters']['properties']['revision'])
step_result = self.m.buildbucket.put(
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 from Milo API to buildbucket v2.
data_json = self.m.step(
'Fetch changes',
[
'prpc', 'call', '-format=json', MILO_HOST,
'milo.Buildbot.GetBuildbotBuildJSON'
],
stdin=self.m.json.input({
'master': self.m.properties['mastername'],
'builder': self.m.buildbucket.builder_name,
'buildNum': self.m.buildbucket.build.number,
}),
stdout=self.m.json.output(),
infra_step=True,
step_test_data=lambda: self.m.json.test_api.output_stream({
'data': base64.b64encode(
self.m.json.dumps(self.test_api.example_buildbot_changes())),
}),
).stdout
change_json = self.m.json.loads(base64.b64decode(data_json['data']))
changes = change_json['sourceStamp']['changes']
assert changes
changes = sorted(changes, key=lambda c: c['when'])
oldest_change = changes[0]['revision']
newest_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' % (oldest_change, newest_change),
limit=100,
step_name='Get change range',
step_test_data=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):
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)