blob: 4aea3dadbd64306291a7d569a44ec6fa439eb42e [file] [log] [blame]
# Copyright 2021, The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Implementation of Atest's Bazel mode.
Bazel mode runs tests using Bazel by generating a synthetic workspace that
contains test targets. Using Bazel allows Atest to leverage features such as
sandboxing, caching, and remote execution.
"""
# pylint: disable=missing-function-docstring
# pylint: disable=missing-class-docstring
import dataclasses
import os
import shutil
from abc import ABC, abstractmethod
from collections import defaultdict, OrderedDict
from pathlib import Path
from typing import Any, Dict, IO, List, Set
import atest_utils
import constants
import module_info
from test_finders import test_finder_base
from test_runners import test_runner_base
def generate_bazel_workspace(mod_info: module_info.ModuleInfo):
"""Generate or update the Bazel workspace used for running tests."""
src_root_path = Path(os.environ.get(constants.ANDROID_BUILD_TOP))
workspace_generator = WorkspaceGenerator(
src_root_path, src_root_path.joinpath('out/atest_bazel_workspace'),
Path(os.environ.get(constants.ANDROID_PRODUCT_OUT)),
Path(os.environ.get(constants.ANDROID_HOST_OUT)),
Path(atest_utils.get_build_out_dir()), mod_info)
workspace_generator.generate()
class WorkspaceGenerator:
"""Class for generating a Bazel workspace."""
# pylint: disable=too-many-arguments
def __init__(self, src_root_path: Path, workspace_out_path: Path,
product_out_path: Path, host_out_path: Path,
build_out_dir: Path, mod_info: module_info.ModuleInfo):
"""Initializes the generator.
Args:
src_root_path: Path of the ANDROID_BUILD_TOP.
workspace_out_path: Path where the workspace will be output.
product_out_path: Path of the ANDROID_PRODUCT_OUT.
host_out_path: Path of the ANDROID_HOST_OUT.
build_out_dir: Path of OUT_DIR
mod_info: ModuleInfo object.
"""
self.src_root_path = src_root_path
self.workspace_out_path = workspace_out_path
self.product_out_path = product_out_path
self.host_out_path = host_out_path
self.build_out_dir = build_out_dir
self.mod_info = mod_info
self.mod_info_md5_path = self.workspace_out_path.joinpath(
'mod_info_md5')
self.path_to_package = {}
self.prerequisite_modules = {
'adb',
'tradefed',
'tradefed-contrib',
'tradefed-test-framework',
'atest-tradefed',
'atest_tradefed.sh',
'atest_script_help.sh',
}
def generate(self):
"""Generate the Bazel workspace if mod_info doesn't exist or stale."""
if atest_utils.check_md5(self.mod_info_md5_path):
return
atest_utils.colorful_print("Generating Bazel workspace.\n",
constants.RED)
if self.workspace_out_path.exists():
# We raise an exception if rmtree fails to avoid leaving stale
# files in the workspace that could interfere with execution.
shutil.rmtree(self.workspace_out_path)
self._add_prerequisite_module_targets()
self._add_test_module_targets()
self.workspace_out_path.mkdir(parents=True)
self._generate_artifacts()
atest_utils.save_md5([str(self.mod_info.mod_info_file_path)],
self.mod_info_md5_path)
def _add_prerequisite_module_targets(self):
for module_name in self.prerequisite_modules:
self._add_target(module_name)
def _add_test_module_targets(self):
for name, info in self.mod_info.name_to_module_info.items():
# Ignore modules that have a 'host_cross_' prefix since they are
# duplicates of existing modules. For example,
# 'host_cross_aapt2_tests' is a duplicate of 'aapt2_tests'. We also
# ignore modules with a '_32' suffix since these also are redundant
# given that modules have both 32 and 64-bit variants built by
# default. See b/77288544#comment6 and b/23566667 for more context.
if name.endswith("_32") or name.startswith("host_cross_"):
continue
if not self.is_host_unit_test(info):
continue
if not self.mod_info.is_testable_module(info):
continue
self._add_target(name)
def _add_target(self, module_name):
info = self._get_module_info(module_name)
path = self._get_module_path(module_name, info)
package = self.path_to_package.setdefault(path, Package(path))
package.add_target(SoongPrebuiltTarget.create(
self, info, self.mod_info.is_testable_module(info)))
if self.is_host_unit_test(info):
package.add_target(DevicelessTestTarget.create_for_test_target(
module_name))
def _get_module_info(self, module_name: str) -> {str:[str]}:
info = self.mod_info.get_module_info(module_name)
if not info:
raise Exception(f'Could not find module `{module_name}` in'
f' module_info file')
return info
def _get_module_path(self, module_name: str, info: Dict[str, Any]) -> str:
mod_path = info.get(constants.MODULE_PATH)
if len(mod_path) != 1:
# We usually have a single path but there are a few exceptions for
# modules like libLLVM_android and libclang_android.
# TODO(nelsonli): Remove this check once b/153609531 is fixed.
raise Exception(f'Module `{module_name}` does not have exactly one'
f' path: {mod_path}')
return mod_path[0]
def is_host_unit_test(self, info: Dict[str, Any]) -> bool:
return self.mod_info.is_suite_in_compatibility_suites(
'host-unit-tests', info)
def _generate_artifacts(self):
"""Generate workspace files on disk."""
self._create_base_files()
self._create_rules_dir()
for package in self.path_to_package.values():
package.generate(self.workspace_out_path)
def _create_rules_dir(self):
symlink = self.workspace_out_path.joinpath('bazel/rules')
symlink.parent.mkdir(parents=True)
symlink.symlink_to(self.src_root_path.joinpath(
'tools/asuite/atest/bazel/rules'))
def _create_base_files(self):
self.workspace_out_path.joinpath('WORKSPACE').touch()
self.workspace_out_path.joinpath('.bazelrc').symlink_to(
self.src_root_path.joinpath('tools/asuite/atest/bazel/bazelrc'))
class Package:
"""Class for generating an entire Package on disk."""
def __init__(self, path: str):
self.path = path
self.imports = defaultdict(set)
self.name_to_target = OrderedDict()
def add_target(self, target):
target_name = target.name()
if target_name in self.name_to_target:
raise Exception(f'Cannot add target `{target_name}` which already'
f' exists in package `{self.path}`')
self.name_to_target[target_name] = target
for i in target.required_imports():
self.imports[i.bzl_package].add(i.symbol)
def generate(self, workspace_out_path: Path):
package_dir = workspace_out_path.joinpath(self.path)
package_dir.mkdir(parents=True, exist_ok=True)
self._create_filesystem_layout(package_dir)
self._write_build_file(package_dir)
def _create_filesystem_layout(self, package_dir: Path):
for target in self.name_to_target.values():
target.create_filesystem_layout(package_dir)
def _write_build_file(self, package_dir: Path):
with package_dir.joinpath('BUILD.bazel').open('w') as f:
f.write('package(default_visibility = ["//visibility:public"])\n')
f.write('\n')
for bzl_package, symbols in sorted(self.imports.items()):
symbols_text = ', '.join('"%s"' % s for s in sorted(symbols))
f.write(f'load("{bzl_package}", {symbols_text})\n')
for target in self.name_to_target.values():
f.write('\n')
target.write_to_build_file(f)
@dataclasses.dataclass(frozen=True)
class Import:
bzl_package: str
symbol: str
@dataclasses.dataclass(frozen=True)
class Config:
name: str
out_path: Path
class Target(ABC):
"""Abstract class for a Bazel target."""
@abstractmethod
def name(self):
pass
def required_imports(self) -> Set[Import]:
return set()
def write_to_build_file(self, f: IO):
pass
def create_filesystem_layout(self, package_dir: Path):
pass
class DevicelessTestTarget(Target):
"""Class for generating a deviceless test target."""
@staticmethod
def create_for_test_target(test_target_name):
return DevicelessTestTarget(f'{test_target_name}_host',
test_target_name)
def __init__(self, name: str, test_target_name: str):
self._name = name
self._test_target_name = test_target_name
def name(self):
return self._name
def required_imports(self) -> Set[Import]:
return {
Import('//bazel/rules:tradefed_test.bzl',
'tradefed_deviceless_test'),
}
def write_to_build_file(self, f: IO):
def fprint(text):
print(text, file=f)
fprint('tradefed_deviceless_test(')
fprint(f' name = "{self._name}",')
fprint(f' test = ":{self._test_target_name}",')
fprint(')')
class SoongPrebuiltTarget(Target):
"""Class for generating a Soong prebuilt target on disk."""
@staticmethod
def create(gen: WorkspaceGenerator, info: Dict[str, Any],
test_module=False):
module_name = info['module_name']
configs = [
Config('host', gen.host_out_path),
Config('device', gen.product_out_path),
]
installed_paths = get_module_installed_paths(info, gen.src_root_path)
config_files = group_paths_by_config(configs, installed_paths)
# For test modules, we only create symbolic link to the 'testcases'
# directory since the information in module-info is not accurate.
if test_module:
config_files = {c: [c.out_path.joinpath(f'testcases/{module_name}')]
for c in config_files.keys()}
if not config_files:
raise Exception(f'Module `{module_name}` does not have any'
f' installed paths')
return SoongPrebuiltTarget(module_name, config_files)
def __init__(self, name: str, config_files: Dict[Config, List[Path]]):
self._name = name
self.config_files = config_files
def name(self):
return self._name
def required_imports(self) -> Set[Import]:
return {
Import('//bazel/rules:soong_prebuilt.bzl', 'soong_prebuilt'),
}
def write_to_build_file(self, f: IO):
def fprint(text):
print(text, file=f)
fprint('soong_prebuilt(')
fprint(f' name = "{self._name}",')
fprint(' files = select({')
for config in sorted(self.config_files.keys(), key=lambda c: c.name):
fprint(f' "//bazel/rules:{config.name}":'
f' glob(["{self._name}/{config.name}/**/*"]),')
fprint(' }),')
fprint(')')
def create_filesystem_layout(self, package_dir: Path):
prebuilts_dir = package_dir.joinpath(self._name)
prebuilts_dir.mkdir()
for config, files in self.config_files.items():
config_prebuilts_dir = prebuilts_dir.joinpath(config.name)
config_prebuilts_dir.mkdir()
for f in files:
rel_path = f.relative_to(config.out_path)
symlink = config_prebuilts_dir.joinpath(rel_path)
symlink.parent.mkdir(parents=True, exist_ok=True)
symlink.symlink_to(f)
def group_paths_by_config(
configs: List[Config], paths: List[Path]) -> Dict[Config, List[Path]]:
config_files = defaultdict(list)
for f in paths:
matching_configs = [
c for c in configs if _is_relative_to(f, c.out_path)]
# The path can only appear in ANDROID_HOST_OUT for host target or
# ANDROID_PRODUCT_OUT, but cannot appear in both.
if len(matching_configs) != 1:
raise Exception(f'Installed path `{f}` is not in'
f' ANDROID_HOST_OUT or ANDROID_PRODUCT_OUT')
config_files[matching_configs[0]].append(f)
return config_files
def _is_relative_to(path1: Path, path2: Path) -> bool:
"""Return True if the path is relative to another path or False."""
# Note that this implementation is required because Path.is_relative_to only
# exists starting with Python 3.9.
try:
path1.relative_to(path2)
return True
except ValueError:
return False
def get_module_installed_paths(
info: Dict[str, Any], src_root_path: Path) -> List[Path]:
# Install paths in module-info are usually relative to the Android
# source root ${ANDROID_BUILD_TOP}. When the output directory is
# customized by the user however, the install paths are absolute.
def resolve(install_path_string):
install_path = Path(install_path_string)
if not install_path.expanduser().is_absolute():
return src_root_path.joinpath(install_path)
return install_path
return map(resolve, info.get(constants.MODULE_INSTALLED))
def _decorate_find_method(mod_info, finder_method_func):
"""A finder_method decorator to override TestInfo properties."""
def use_bazel_runner(finder_obj, test_id):
test_infos = finder_method_func(finder_obj, test_id)
if not test_infos:
return test_infos
for tinfo in test_infos:
m_info = mod_info.get_module_info(tinfo.test_name)
if mod_info.is_unit_test(m_info):
tinfo.test_runner = BazelTestRunner.NAME
return test_infos
return use_bazel_runner
def create_new_finder(mod_info, finder):
"""Create new test_finder_base.Finder with decorated find_method.
Args:
mod_info: ModuleInfo object.
finder: Test Finder class.
Returns:
List of ordered find methods.
"""
return test_finder_base.Finder(finder.test_finder_instance,
_decorate_find_method(
mod_info,
finder.find_method),
finder.finder_info)
class BazelTestRunner(test_runner_base.TestRunnerBase):
"""Bazel Test Runner class."""
NAME = 'BazelTestRunner'
EXECUTABLE = 'none'
# pylint: disable=unused-argument
def run_tests(self, test_infos, extra_args, reporter):
"""Run the list of test_infos.
Args:
test_infos: List of TestInfo.
extra_args: Dict of extra args to add to test run.
reporter: An instance of result_report.ResultReporter
"""
reporter.register_unsupported_runner(self.NAME)
ret_code = constants.EXIT_CODE_SUCCESS
run_cmds = self.generate_run_commands(test_infos, extra_args)
for run_cmd in run_cmds:
subproc = self.run(run_cmd,
output_to_stdout=True)
ret_code |= self.wait_for_subprocess(subproc)
return ret_code
def host_env_check(self):
"""Check that host env has everything we need.
We actually can assume the host env is fine because we have the same
requirements that atest has. Update this to check for android env vars
if that changes.
"""
def get_test_runner_build_reqs(self):
"""Return the build requirements.
Returns:
Set of build targets.
"""
return set()
# pylint: disable=unused-argument
# pylint: disable=unused-variable
def generate_run_commands(self, test_infos, extra_args, port=None):
"""Generate a list of run commands from TestInfos.
Args:
test_infos: A set of TestInfo instances.
extra_args: A Dict of extra args to append.
port: Optional. An int of the port number to send events to.
Returns:
A list of run commands to run the tests.
"""
run_cmds = []
for tinfo in test_infos:
run_cmds.append('echo "bazel test";')
return run_cmds