Stream build process output

Ensure that output and errors from the underlying build command are
streamed for real-time build progress updates and debugging.

This change strips out all code that is currently unecessary and adds
tests for the remaining functionality.

Test: atest --host build_test_suites_local_test build_test_suites_test
Bug: 330365727
Change-Id: I7ef98d6654fe1435cf67c15e2c516a0967e03a75
This commit is contained in:
Luca Farsi
2024-03-26 10:55:21 -07:00
parent 2fc519a568
commit db136447a6
7 changed files with 645 additions and 363 deletions

View File

@@ -12,404 +12,115 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Script to build only the necessary modules for general-tests along
with whatever other targets are passed in.
"""
"""Build script for the CI `test_suites` target."""
import argparse
from collections.abc import Sequence
import json
import logging
import os
import pathlib
import re
import subprocess
import sys
from typing import Any
import test_mapping_module_retriever
# List of modules that are always required to be in general-tests.zip
REQUIRED_MODULES = frozenset(
['cts-tradefed', 'vts-tradefed', 'compatibility-host-util', 'soong_zip']
)
class Error(Exception):
def __init__(self, message):
super().__init__(message)
def build_test_suites(argv):
class BuildFailureError(Error):
def __init__(self, return_code):
super().__init__(f'Build command failed with return code: f{return_code}')
self.return_code = return_code
REQUIRED_ENV_VARS = frozenset(['TARGET_PRODUCT', 'TARGET_RELEASE', 'TOP'])
SOONG_UI_EXE_REL_PATH = 'build/soong/soong_ui.bash'
def get_top() -> pathlib.Path:
return pathlib.Path(os.environ['TOP'])
def build_test_suites(argv: list[str]) -> int:
"""Builds the general-tests and any other test suites passed in.
Args:
argv: The command line arguments passed in.
Returns:
The exit code of the build.
"""
args = parse_args(argv)
check_required_env()
if is_optimization_enabled():
# Call the class to map changed files to modules to build.
# TODO(lucafarsi): Move this into a replaceable class.
build_affected_modules(args)
else:
try:
build_everything(args)
except BuildFailureError as e:
logging.error('Build command failed! Check build_log for details.')
return e.return_code
return 0
def check_required_env():
"""Check for required env vars.
Raises:
RuntimeError: If any required env vars are not found.
"""
missing_env_vars = sorted(v for v in REQUIRED_ENV_VARS if v not in os.environ)
if not missing_env_vars:
return
t = ','.join(missing_env_vars)
raise Error(f'Missing required environment variables: {t}')
def parse_args(argv):
argparser = argparse.ArgumentParser()
argparser.add_argument(
'extra_targets', nargs='*', help='Extra test suites to build.'
)
argparser.add_argument('--target_product')
argparser.add_argument('--target_release')
argparser.add_argument(
'--with_dexpreopt_boot_img_and_system_server_only', action='store_true'
)
argparser.add_argument('--change_info', nargs='?')
return argparser.parse_args()
def is_optimization_enabled() -> bool:
# TODO(lucafarsi): switch back to building only affected general-tests modules
# in presubmit once ready.
# if os.environ.get('BUILD_NUMBER')[0] == 'P':
# return True
return False
return argparser.parse_args(argv)
def build_everything(args: argparse.Namespace):
"""Builds all tests (regardless of whether they are needed).
Args:
args: The parsed arguments.
Raises:
BuildFailure: If the build command fails.
"""
build_command = base_build_command(args, args.extra_targets)
build_command.append('general-tests')
run_command(build_command, print_output=True)
def build_affected_modules(args: argparse.Namespace):
modules_to_build = find_modules_to_build(
pathlib.Path(args.change_info), args.extra_required_modules
)
# Call the build command with everything.
build_command = base_build_command(args, args.extra_targets)
build_command.extend(modules_to_build)
# When not building general-tests we also have to build the general tests
# shared libs.
build_command.append('general-tests-shared-libs')
run_command(build_command, print_output=True)
zip_build_outputs(modules_to_build, args.target_release)
try:
run_command(build_command)
except subprocess.CalledProcessError as e:
raise BuildFailureError(e.returncode) from e
def base_build_command(
args: argparse.Namespace, extra_targets: set[str]
) -> list:
) -> list[str]:
build_command = []
build_command.append('time')
build_command.append('./build/soong/soong_ui.bash')
build_command.append(get_top().joinpath(SOONG_UI_EXE_REL_PATH))
build_command.append('--make-mode')
build_command.append('dist')
build_command.append('TARGET_PRODUCT=' + args.target_product)
build_command.append('TARGET_RELEASE=' + args.target_release)
if args.with_dexpreopt_boot_img_and_system_server_only:
build_command.append('WITH_DEXPREOPT_BOOT_IMG_AND_SYSTEM_SERVER_ONLY=true')
build_command.extend(extra_targets)
return build_command
def run_command(
args: list[str],
env: dict[str, str] = os.environ,
print_output: bool = False,
) -> str:
result = subprocess.run(
args=args,
text=True,
capture_output=True,
check=False,
env=env,
)
# If the process failed, print its stdout and propagate the exception.
if not result.returncode == 0:
print('Build command failed! output:')
print('stdout: ' + result.stdout)
print('stderr: ' + result.stderr)
result.check_returncode()
if print_output:
print(result.stdout)
return result.stdout
def find_modules_to_build(
change_info: pathlib.Path, extra_required_modules: list[str]
) -> set[str]:
changed_files = find_changed_files(change_info)
test_mappings = test_mapping_module_retriever.GetTestMappings(
changed_files, set()
)
# Soong_zip is required to generate the output zip so always build it.
modules_to_build = set(REQUIRED_MODULES)
if extra_required_modules:
modules_to_build.update(extra_required_modules)
modules_to_build.update(find_affected_modules(test_mappings, changed_files))
return modules_to_build
def find_changed_files(change_info: pathlib.Path) -> set[str]:
with open(change_info) as change_info_file:
change_info_contents = json.load(change_info_file)
changed_files = set()
for change in change_info_contents['changes']:
project_path = change.get('projectPath') + '/'
for revision in change.get('revisions'):
for file_info in revision.get('fileInfos'):
changed_files.add(project_path + file_info.get('path'))
return changed_files
def find_affected_modules(
test_mappings: dict[str, Any], changed_files: set[str]
) -> set[str]:
modules = set()
# The test_mappings object returned by GetTestMappings is organized as
# follows:
# {
# 'test_mapping_file_path': {
# 'group_name' : [
# 'name': 'module_name',
# ],
# }
# }
for test_mapping in test_mappings.values():
for group in test_mapping.values():
for entry in group:
module_name = entry.get('name', None)
if not module_name:
continue
file_patterns = entry.get('file_patterns')
if not file_patterns:
modules.add(module_name)
continue
if matches_file_patterns(file_patterns, changed_files):
modules.add(module_name)
continue
return modules
# TODO(lucafarsi): Share this logic with the original logic in
# test_mapping_test_retriever.py
def matches_file_patterns(
file_patterns: list[set], changed_files: set[str]
) -> bool:
for changed_file in changed_files:
for pattern in file_patterns:
if re.search(pattern, changed_file):
return True
return False
def zip_build_outputs(
modules_to_build: set[str], target_release: str
):
src_top = os.environ.get('TOP', os.getcwd())
# Call dumpvars to get the necessary things.
# TODO(lucafarsi): Don't call soong_ui 4 times for this, --dumpvars-mode can
# do it but it requires parsing.
host_out_testcases = pathlib.Path(
get_soong_var('HOST_OUT_TESTCASES', target_release)
)
target_out_testcases = pathlib.Path(
get_soong_var('TARGET_OUT_TESTCASES', target_release)
)
product_out = pathlib.Path(get_soong_var('PRODUCT_OUT', target_release))
soong_host_out = pathlib.Path(get_soong_var('SOONG_HOST_OUT', target_release))
host_out = pathlib.Path(get_soong_var('HOST_OUT', target_release))
dist_dir = pathlib.Path(get_soong_var('DIST_DIR', target_release))
# Call the class to package the outputs.
# TODO(lucafarsi): Move this code into a replaceable class.
host_paths = []
target_paths = []
host_config_files = []
target_config_files = []
for module in modules_to_build:
host_path = os.path.join(host_out_testcases, module)
if os.path.exists(host_path):
host_paths.append(host_path)
collect_config_files(src_top, host_path, host_config_files)
target_path = os.path.join(target_out_testcases, module)
if os.path.exists(target_path):
target_paths.append(target_path)
collect_config_files(src_top, target_path, target_config_files)
zip_test_configs_zips(
dist_dir, host_out, product_out, host_config_files, target_config_files
)
zip_command = base_zip_command(host_out, dist_dir, 'general-tests.zip')
# Add host testcases.
zip_command.append('-C')
zip_command.append(os.path.join(src_top, soong_host_out))
zip_command.append('-P')
zip_command.append('host/')
for path in host_paths:
zip_command.append('-D')
zip_command.append(path)
# Add target testcases.
zip_command.append('-C')
zip_command.append(os.path.join(src_top, product_out))
zip_command.append('-P')
zip_command.append('target')
for path in target_paths:
zip_command.append('-D')
zip_command.append(path)
# TODO(lucafarsi): Push this logic into a general-tests-minimal build command
# Add necessary tools. These are also hardcoded in general-tests.mk.
framework_path = os.path.join(soong_host_out, 'framework')
zip_command.append('-C')
zip_command.append(framework_path)
zip_command.append('-P')
zip_command.append('host/tools')
zip_command.append('-f')
zip_command.append(os.path.join(framework_path, 'cts-tradefed.jar'))
zip_command.append('-f')
zip_command.append(
os.path.join(framework_path, 'compatibility-host-util.jar')
)
zip_command.append('-f')
zip_command.append(os.path.join(framework_path, 'vts-tradefed.jar'))
run_command(zip_command, print_output=True)
def collect_config_files(
src_top: pathlib.Path, root_dir: pathlib.Path, config_files: list[str]
):
for root, dirs, files in os.walk(os.path.join(src_top, root_dir)):
for file in files:
if file.endswith('.config'):
config_files.append(os.path.join(root_dir, file))
def base_zip_command(
host_out: pathlib.Path, dist_dir: pathlib.Path, name: str
) -> list[str]:
return [
'time',
os.path.join(host_out, 'bin', 'soong_zip'),
'-d',
'-o',
os.path.join(dist_dir, name),
]
# generate general-tests_configs.zip which contains all of the .config files
# that were built and general-tests_list.zip which contains a text file which
# lists all of the .config files that are in general-tests_configs.zip.
#
# general-tests_comfigs.zip is organized as follows:
# /
# host/
# testcases/
# test_1.config
# test_2.config
# ...
# target/
# testcases/
# test_1.config
# test_2.config
# ...
#
# So the process is we write out the paths to all the host config files into one
# file and all the paths to the target config files in another. We also write
# the paths to all the config files into a third file to use for
# general-tests_list.zip.
def zip_test_configs_zips(
dist_dir: pathlib.Path,
host_out: pathlib.Path,
product_out: pathlib.Path,
host_config_files: list[str],
target_config_files: list[str],
):
with open(
os.path.join(host_out, 'host_general-tests_list'), 'w'
) as host_list_file, open(
os.path.join(product_out, 'target_general-tests_list'), 'w'
) as target_list_file, open(
os.path.join(host_out, 'general-tests_list'), 'w'
) as list_file:
for config_file in host_config_files:
host_list_file.write(config_file + '\n')
list_file.write('host/' + os.path.relpath(config_file, host_out) + '\n')
for config_file in target_config_files:
target_list_file.write(config_file + '\n')
list_file.write(
'target/' + os.path.relpath(config_file, product_out) + '\n'
)
tests_config_zip_command = base_zip_command(
host_out, dist_dir, 'general-tests_configs.zip'
)
tests_config_zip_command.append('-P')
tests_config_zip_command.append('host')
tests_config_zip_command.append('-C')
tests_config_zip_command.append(host_out)
tests_config_zip_command.append('-l')
tests_config_zip_command.append(
os.path.join(host_out, 'host_general-tests_list')
)
tests_config_zip_command.append('-P')
tests_config_zip_command.append('target')
tests_config_zip_command.append('-C')
tests_config_zip_command.append(product_out)
tests_config_zip_command.append('-l')
tests_config_zip_command.append(
os.path.join(product_out, 'target_general-tests_list')
)
run_command(tests_config_zip_command, print_output=True)
tests_list_zip_command = base_zip_command(
host_out, dist_dir, 'general-tests_list.zip'
)
tests_list_zip_command.append('-C')
tests_list_zip_command.append(host_out)
tests_list_zip_command.append('-f')
tests_list_zip_command.append(os.path.join(host_out, 'general-tests_list'))
run_command(tests_list_zip_command, print_output=True)
def get_soong_var(var: str, target_release: str) -> str:
new_env = os.environ.copy()
new_env['TARGET_RELEASE'] = target_release
value = run_command(
['./build/soong/soong_ui.bash', '--dumpvar-mode', '--abs', var],
env=new_env,
).strip()
if not value:
raise RuntimeError('Necessary soong variable ' + var + ' not found.')
return value
def run_command(args: list[str], stdout=None):
subprocess.run(args=args, check=True, stdout=stdout)
def main(argv):
build_test_suites(argv)
sys.exit(build_test_suites(argv))