blob: 5ef5168431705bb95a75ec1cdd52afb6303c92cc [file] [log] [blame]
# Copyright 2017 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Chrome utility."""
import json
import logging
import os
import re
import shutil
import subprocess
import tempfile
import time
import urllib.request
from bisect_kit import chromite_util
from bisect_kit import errors
from bisect_kit import git_util
from bisect_kit import locking
from bisect_kit import util
logger = logging.getLogger(__name__)
RE_CHROME_VERSION = r'^\d+\.\d+\.\d+\.\d+$'
CHROME_BINARIES = ['chrome']
CHROME_BINARIES_OPTIONAL = ['nacl_helper']
# This list is created manually by inspecting
# src/third_party/chromiumos-overlay/chromeos-base/chrome-binary-tests/
# chrome-binary-tests-0.0.1.ebuild
# TODO(kcwu): we can get rid of this table once we migrated to build chrome
# using chromeos ebuild rules.
CHROME_TEST_BINARIES = [
'capture_unittests',
'dawn_end2end_tests',
'dawn_unittests',
'decode_test',
'fake_dmserver',
'libtest_trace_processor.so',
'image_processor_test',
'image_processor_perf_test',
'jpeg_decode_accelerator_unittest',
'jpeg_encode_accelerator_unittest',
'ozone_gl_unittests',
'ozone_integration_tests',
'sandbox_linux_unittests',
'screen_ai_ocr_perf_test',
'v4l2_stateless_decoder',
'v4l2_unittest',
'vaapi_unittest',
'video_decode_accelerator_perf_tests',
'video_decode_accelerator_tests',
'video_encode_accelerator_perf_tests',
'video_encode_accelerator_tests',
'vulkan_overlay_adaptor_test',
'wayland_client_integration_tests',
'wayland_client_perftests',
# Removed binaries. Keep them here for users to bisect old versions.
'video_decode_accelerator_unittest',
'video_encode_accelerator_unittest',
]
def is_chrome_version(s):
"""Is a chrome version string?"""
return bool(re.match(RE_CHROME_VERSION, s))
def is_commit_position(s):
"""Returns whether the given string is a chrome commit position."""
return bool(re.match(r'^[^@]+@{#\d+}$', s))
def _canonical_build_number(s):
"""Returns canonical build number.
"The BUILD and PATCH numbers together are the canonical representation of
what code is in a given release."
ref: https://www.chromium.org/developers/version-numbers
This format is used for version comparison.
Args:
s: chrome version string, in format MAJOR.MINOR.BUILD.PATCH
Returns:
BUILD.PATCH
"""
assert s.count('.') == 3
return '.'.join(s.split('.')[2:])
def is_version_lesseq(a, b):
"""Compares whether Chrome version `a` is less or equal to version `b`."""
return util.is_version_lesseq(
_canonical_build_number(a), _canonical_build_number(b)
)
def is_direct_relative_version(a, b):
return util.is_direct_relative_version(
_canonical_build_number(a), _canonical_build_number(b)
)
def extract_branch_from_version(rev):
"""Extracts branch number from version string
Args:
rev: chrome version string
Returns:
branch number (str). For example, return '3064' for '59.0.3064.0'.
"""
return rev.split('.')[2]
def extract_milestone_from_version(rev: str) -> str:
"""Extracts milestone from version string
Args:
rev: chrome version string
Returns:
milestone (str). For example, return '59' for '59.0.3064.0'.
"""
return rev.split('.')[0]
def argtype_chrome_version(s):
if not is_chrome_version(s):
raise errors.ArgTypeError('invalid chrome version', '59.0.3064.0')
return s
def get_commit_position(metadata: git_util.CommitMeta) -> str | None:
if not metadata.message:
return None
for line in reversed(metadata.message.splitlines()):
if line.startswith('Cr-Commit-Position: '):
return line.split(' ', 1)[1]
if line == '' or line.isspace():
break
return None
def query_git_rev_by_commit_position(chrome_src, commit_position):
"""Looks up git commit by chrome's commit position.
This function cannot find early commits which is still using 'git-svn-id'.
Args:
chrome_src: git repo path of chrome/src
commit_position: chrome commit position (e.g. 'refs/heads/main@{#1364755}')
Returns:
git commit id
Raises:
ValueError: unable to find commits with given commit_position.
"""
rev = util.check_output(
'git',
'rev-list',
'-1',
'--all',
'--grep',
'^Cr-Commit-Position: %s' % commit_position,
cwd=chrome_src,
).strip()
if not rev:
raise ValueError('unable to find commits with given commit_position')
return rev
def query_git_rev(chrome_src, rev):
"""Guesses git commit by heuristic.
Args:
chrome_src: git repo path of chrome/src
rev: could be
chrome version, commit position, or git hash
Returns:
git commit hash
"""
if is_chrome_version(rev):
try:
# Query via git tag
return git_util.get_commit_hash(chrome_src, rev)
except ValueError as e:
# Failed due to source code not synced yet? Anyway, query by web api
url = 'https://omahaproxy.appspot.com/deps.json?version=' + rev
logger.debug('fetch %s', url)
content = urllib.request.urlopen(url).read().decode('utf8')
obj = json.loads(content)
rev = obj['chromium_commit']
if not git_util.is_git_rev(rev):
raise ValueError(
'The response of omahaproxy, %s, is not a git commit hash'
% rev
) from e
if git_util.is_git_rev(rev):
return rev
# Cr-Commit-Position
m = re.match(r'^#(\d+)$', rev)
if m:
return query_git_rev_by_commit_position(
chrome_src, 'refs/heads/main@{#%s}' % m.group(1)
)
raise ValueError('unknown rev format: %s' % rev)
def _simple_chrome_shell(
chrome_src: str,
board: str,
command: list[str],
is_public_build: bool = False,
env: dict[str, str] | None = None,
timeout: int | None = None,
) -> str:
"""Runs a command inside the chrome-sdk shell.
This is a wrapper around `cros chrome-sdk`.
Args:
chrome_src: Git repo path of chrome/src.
board: ChromeOS board name.
command: Command to run inside the shell.
is_public_build: Whether this is a public build.
env: Environment variables for the command.
timeout: Timeout in seconds for the command.
"""
prefix = [
'cros',
'chrome-sdk',
'--board=%s' % board,
]
if is_public_build:
prefix.append('--use-external-config')
else:
prefix.append('--internal')
prefix.append('--use-remoteexec')
lkgm_file = os.path.join(chrome_src, 'chromeos', 'CHROMEOS_LKGM')
if os.path.exists(lkgm_file):
with open(lkgm_file) as f:
lkgm_version = f.read().strip()
# This work around b/205818006
if util.is_version_lesseq(
'14285.0.0', lkgm_version
) and util.is_version_lesseq(lkgm_version, '14309.0.0'):
prefix += ['--version', '14284.0.0']
env = (env if env is not None else os.environ).copy()
# This work around http://crbug.com/658104, which depot_tool can't find GN
# path correctly.
env['CHROMIUM_BUILDTOOLS_PATH'] = os.path.abspath(
os.path.join(chrome_src, 'buildtools')
)
# Add the environment variables for reproxy
env.update(get_RBE_environment_variables())
cmd = prefix + ['--'] + list(command)
return chromite_util.check_output(
*cmd, cwd=chrome_src, env=env, timeout=timeout
)
def determine_targets_to_build(
chrome_src: str,
board: str,
is_public_build: bool,
with_tests: bool = True,
):
logger.info('is_public_build %s', is_public_build)
out_dir = os.path.join('out_%s' % board, 'Release')
try:
available_targets = _simple_chrome_shell(
chrome_src,
board,
command=[
'buildtools/linux64/gn',
'ls',
'--type=executable',
'--as=output',
out_dir,
],
is_public_build=is_public_build,
).splitlines()
except subprocess.CalledProcessError as e:
raise errors.BuildError(
'failed to determine chrome targets to build'
) from e
mandatory = CHROME_BINARIES
optional = list(CHROME_BINARIES_OPTIONAL)
if with_tests:
optional += CHROME_TEST_BINARIES
result = []
for binary in mandatory:
assert binary in available_targets, (
'build rule for %r is not found?' % binary
)
result.append(binary)
for binary in optional:
# b/258539287: nacl_helper is not shown in `gn ls`
if (
binary == 'nacl_helper'
and binary not in available_targets
and 'nacl_helper_arm32/nacl_helper' in available_targets
):
result.append(binary)
logger.warning(
'"nacl_helper" not found but "nacl_helper_arm32/nacl_helper" '
'in gn ls, force adding "nacl_helper" to targets list'
)
continue
if binary not in available_targets:
continue
result.append(binary)
return result
def build(
chrome_src: str,
board: str,
targets: list[str],
is_public_build: bool,
with_tests: bool = True,
) -> str:
"""Build Chrome binaries.
Args:
chrome_src: git repo path of chrome/src.
board: ChromeOS board name.
targets: ninja targets. If empty, targets are determined automatically.
is_public_build: Whether this is a public build.
with_tests: Build test binaries as well if targets is not specified.
Returns:
The output directory of the build.
"""
if not targets:
targets = determine_targets_to_build(
chrome_src,
board,
is_public_build=is_public_build,
with_tests=with_tests,
)
logger.info('build %s', targets)
out_dir = os.path.join('out_%s' % board, 'Release')
with locking.lock_file(locking.LOCK_FILE_FOR_BUILD):
# Need to call .py directly, since it is calling the non-initialized
# (non-bootstrapped) script.
# See. https://chromium.googlesource.com/chromium/tools/depot_tools/+/HEAD/README.md#installing
cmd = [
'./third_party/depot_tools/autoninja.py',
'-C',
out_dir,
] + targets
# Chrome build shouldn't take more than 3 hours. Set timeout to abort
# the process in case it gets deadlocked. b/369498616
timeout = 3 * 60 * 60
try:
_simple_chrome_shell(
chrome_src,
board,
command=cmd,
is_public_build=is_public_build,
timeout=timeout,
)
except subprocess.CalledProcessError:
logger.warning('build failed, delete out directory and retry again')
# Some compiler processes are still terminating. Short delay is
# necessary.
time.sleep(10)
shutil.rmtree(os.path.join(chrome_src, out_dir))
try:
_simple_chrome_shell(
chrome_src,
board,
command=cmd,
is_public_build=is_public_build,
timeout=timeout,
)
except subprocess.CalledProcessError as e:
raise errors.BuildError('failed to build chrome') from e
return os.path.join(chrome_src, out_dir)
def deploy_to_staging_dir(
chrome_src: str,
board: str,
targets: list[str],
out_dir: str,
staging_dir: str,
) -> None:
"""Deploy Chrome binaries to the specified staging directory.
Args:
chrome_src: git repo path of chrome/src
board: ChromeOS board name
targets: ninja targets.
out_dir: output dir from which to deploy binaries.
staging_dir: staging dir to which to deploy binaries.
"""
# Deploy chrome to the staging dir if needed.
if set(CHROME_BINARIES).intersection(targets):
cmd = [
'./third_party/chromite/bin/deploy_chrome',
'--force',
'--board',
board,
'--build-dir',
out_dir,
'--staging-dir',
staging_dir,
'--staging-only',
]
chromite_util.check_output(*cmd, cwd=chrome_src)
# Deploy test binaries to the staging dir if needed.
test_binaries = set(CHROME_TEST_BINARIES).intersection(targets)
for target in test_binaries:
src = os.path.join(out_dir, target)
dest = os.path.join(staging_dir, target)
# Strip the target file.
util.check_call('eu-strip', '--strip-debug', src, '-o', dest)
# chmod o+x.
stat = os.stat(dest)
os.chmod(dest, stat.st_mode | 0o001)
def deploy(
chrome_src: str,
board: str,
dut: str,
targets: list[str],
with_tests: bool = True,
out_dir: str | None = None,
) -> None:
"""Deploy Chrome binaries.
Args:
chrome_src: git repo path of chrome/src.
board: ChromeOS board name.
dut: DUT address.
targets: ninja targets. If empty, deploys any target found in out_dir.
with_tests: Deploy test binaries as well.
out_dir: Optional output dir from which to deploy binaries.
"""
if not targets:
targets = os.listdir(out_dir)
logger.info('deploy %s', targets)
if not out_dir:
out_dir = os.path.join('out_%s' % board, 'Release')
# Deploy chrome if needed.
if set(CHROME_BINARIES).intersection(targets):
cmd = [
'./third_party/chromite/bin/deploy_chrome',
'--force',
'--board',
board,
'--build-dir',
out_dir,
'--device',
dut,
]
try:
chromite_util.check_output(*cmd, cwd=chrome_src)
except subprocess.CalledProcessError as e:
raise errors.ExternalError('chrome deploy failed') from e
# Deploy test binaries if needed.
test_binaries = set(CHROME_TEST_BINARIES).intersection(targets)
if with_tests and test_binaries:
with tempfile.TemporaryDirectory() as staging_dir:
# Deploy test binaries to the staging directory.
deploy_to_staging_dir(
chrome_src,
board,
test_binaries,
out_dir=out_dir,
staging_dir=staging_dir,
)
try:
autotest_test_binary_path = (
"/usr/local/autotest/deps/chrome_test/test_src/out/Release/"
)
tast_test_binary_path = (
"/usr/local/libexec/chrome-binary-tests/"
)
# Copy test binaries to the autotest binary directory.
util.ssh_cmd(
dut,
'mkdir',
'-p',
autotest_test_binary_path,
)
# NOTE: A trailing slash (i.e. '/' at the end of the path) needs
# to be added to staging_dir to copy its contents instead of the
# directory itself (see `man rsync`).
util.check_call(
'rsync',
'-r',
f'{staging_dir}/',
f'{dut}:{autotest_test_binary_path}',
)
# Copy test binaries to the tast binary directory.
util.ssh_cmd(
dut,
'rsync',
'-r',
autotest_test_binary_path,
tast_test_binary_path,
)
except subprocess.CalledProcessError as e:
raise errors.ExternalError(
'Failed to depoy test binaries'
) from e
def _get_ancestors(target_version: str, version_list: list[str]) -> list[str]:
"""Get the ancestor version list of a target version.
Args:
target_version: The version we want to find the ancestors for
version_list: A list of chrome versions
Returns:
A list of ancestor version of target version
"""
ancestors: list[str] = []
for candidate_ancestor in version_list:
if not is_chrome_version(candidate_ancestor):
continue
if is_version_lesseq(
candidate_ancestor, target_version
) and is_direct_relative_version(candidate_ancestor, target_version):
ancestors.append(candidate_ancestor)
return sorted(ancestors, key=util.version_key_func)
def get_lca(old_version: str, new_version: str, version_list: list[str]) -> str:
"""Get the lowest common ancestor of old_version and new_version.
Args:
old_version: Old chrome version
new_version: New chrome version
version_list: A list of chrome versions
Returns:
The lowest common ancestor of old_version and new_version
"""
ancestors_of_old_version = _get_ancestors(old_version, version_list)
ancestors_of_new_version = _get_ancestors(new_version, version_list)
ancestors_of_old_version.sort(key=util.version_key_func)
for old_ancestor in reversed(ancestors_of_old_version):
if old_ancestor in ancestors_of_new_version:
return old_ancestor
raise errors.InternalError(
'Unable to find their common ancestor: %s, %s'
% (old_version, new_version)
)
def get_RBE_environment_variables() -> dict[str, str]:
"""Generate environment variables for reproxy
Returns:
A dictionary containing required variables
"""
env = os.environ.copy()
bisect_runner_json_path = os.environ.get(
'SKYLAB_CLOUD_SERVICE_ACCOUNT_JSON'
)
env['RBE_credential_file'] = bisect_runner_json_path
env['RBE_use_application_default_credentials'] = 'true'
env['RBE_use_gce_credentials'] = 'false'
env['RBE_automatic_auth'] = 'false'
# (b/328578714) reclient now delegate auth to "credentials_helper".
# We need to overwrite its default args to make it work properly.
env['RBE_credentials_helper_args'] = (
'--auth_source=gcloud --gcert_refresh_timeout=20'
)
# Set the same value to experimental_credentials_helper_args which is
# needed to build Chrome older than crrev.com/c/5863651
env['RBE_experimental_credentials_helper_args'] = env[
'RBE_credentials_helper_args'
]
# Autoninja does not allow external accounts on corp machines to
# authenticate with reproxy by default. This flag is used to skip
# that check. For more details, see b/320639792.
env['AUTONINJA_SKIP_EXTERNAL_ACCOUNT_CHECK'] = '1'
return env
def build_revlist(chrome_src: str, old: str, new: str):
"""Build revlist.
Args:
chrome_src: chrome src directory path
old: chrome commit position from which the returned revlist starts
new: chrome commit position on which the returned revlist ends
Returns:
(revlist, details):
revlist: list of rev string
details: dict of rev to rev detail
"""
old_commit_hash = query_git_rev_by_commit_position(chrome_src, old)
new_commit_hash = query_git_rev_by_commit_position(chrome_src, new)
revisions = [old_commit_hash] + git_util.git_output(
[
'rev-list',
'--reverse',
'--first-parent',
'%s..%s' % (old_commit_hash, new_commit_hash),
],
cwd=chrome_src,
).splitlines()
metadata = git_util.get_batch_commit_metadata(chrome_src, revisions)
revlist = []
details = {}
for revision in revisions:
commit_position = get_commit_position(metadata[revision])
if not commit_position:
logger.warning('Failed to get the commit position of %s', revision)
continue
revlist.append(commit_position)
details[commit_position] = {
'actions': [
{
'action_type': 'commit',
'commit_summary': git_util.CommitMeta.get_summary(
git_util.get_commit_metadata(chrome_src, revision)
),
'path': 'src',
'repo_url': 'https://chromium.googlesource.com/chromium/src.git',
'rev': revision,
}
]
}
return (revlist, details)
def get_chrome_commit_hash(details) -> str:
"""Gets the chrome commit hash from details dictionary
Args:
details: details dict returned by `build_revlist()`
Returns:
chrome git commit hash
"""
return details['actions'][0]['rev']
def get_lca_chrome_localbuild(
chrome_src, old_version: str, new_version: str
) -> str:
"""Get the lowest common ancestor of 2 chrome localbuild version
Args:
old_version: Old chrome version
new_version: New chrome version
Returns:
The lowest common ancestor of 2 chrome localbuild version
"""
version_list_chrome: list[str] = []
for rev in git_util.get_tags(chrome_src):
version_list_chrome.append(rev)
return get_lca(old_version, new_version, version_list_chrome)