blob: 2bffdc31822f420c8a5c375b8b0c9a5d00f12265 [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2021 Google, Inc.
#
# 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.
""" Build BT targets on the host system.
For building, you will first have to stage a platform directory that has the
following structure:
|-common-mk
|-bt
|-external
|-|-rust
|-|-|-vendor
The simplest way to do this is to check out platform2 to another directory (that
is not a subdir of this bt directory), symlink bt there and symlink the rust
vendor repository as well.
"""
import argparse
import multiprocessing
import os
import shutil
import six
import subprocess
import sys
import tarfile
import time
# Use flags required by common-mk (find -type f | grep -nE 'use[.]' {})
COMMON_MK_USES = [
'asan',
'coverage',
'cros_host',
'fuzzer',
'fuzzer',
'msan',
'profiling',
'tcmalloc',
'test',
'ubsan',
]
# Default use flags.
USE_DEFAULTS = {
'android': False,
'bt_nonstandard_codecs': False,
'test': False,
}
VALID_TARGETS = [
'all', # All targets except test and clean
'clean', # Clean up output directory
'docs', # Build Rust docs
'main', # Build the main C++ codebase
'prepare', # Prepare the output directory (gn gen + rust setup)
'rust', # Build only the rust components + copy artifacts to output dir
'test', # Run the unit tests
'tools', # Build the host tools (i.e. packetgen)
]
# TODO(b/190750167) - Host tests are disabled until we are full bazel build
HOST_TESTS = [
# 'bluetooth_test_common',
# 'bluetoothtbd_test',
# 'net_test_avrcp',
# 'net_test_btcore',
# 'net_test_types',
# 'net_test_btm_iso',
# 'net_test_btpackets',
]
BOOTSTRAP_GIT_REPOS = {
'platform2': 'https://chromium.googlesource.com/chromiumos/platform2',
'rust_crates': 'https://chromium.googlesource.com/chromiumos/third_party/rust_crates',
'proto_logging': 'https://android.googlesource.com/platform/frameworks/proto_logging'
}
# List of packages required for linux build
REQUIRED_APT_PACKAGES = [
'bison',
'build-essential',
'curl',
'debmake',
'flatbuffers-compiler',
'flex',
'g++-multilib',
'gcc-multilib',
'generate-ninja',
'gnupg',
'gperf',
'libc++abi-dev',
'libc++-dev',
'libdbus-1-dev',
'libdouble-conversion-dev',
'libevent-dev',
'libevent-dev',
'libflatbuffers-dev',
'libflatbuffers1',
'libgl1-mesa-dev',
'libglib2.0-dev',
'libgtest-dev',
'libgmock-dev',
'liblz4-tool',
'libncurses5',
'libnss3-dev',
'libprotobuf-dev',
'libre2-9',
'libre2-dev',
'libssl-dev',
'libtinyxml2-dev',
'libx11-dev',
'libxml2-utils',
'ninja-build',
'openssl',
'protobuf-compiler',
'unzip',
'x11proto-core-dev',
'xsltproc',
'zip',
'zlib1g-dev',
]
# List of cargo packages required for linux build
REQUIRED_CARGO_PACKAGES = ['cxxbridge-cmd']
APT_PKG_LIST = ['apt', '-qq', 'list']
CARGO_PKG_LIST = ['cargo', 'install', '--list']
class UseFlags():
def __init__(self, use_flags):
""" Construct the use flags.
Args:
use_flags: List of use flags parsed from the command.
"""
self.flags = {}
# Import use flags required by common-mk
for use in COMMON_MK_USES:
self.set_flag(use, False)
# Set our defaults
for use, value in USE_DEFAULTS.items():
self.set_flag(use, value)
# Set use flags - value is set to True unless the use starts with -
# All given use flags always override the defaults
for use in use_flags:
value = not use.startswith('-')
self.set_flag(use, value)
def set_flag(self, key, value=True):
setattr(self, key, value)
self.flags[key] = value
class HostBuild():
def __init__(self, args):
""" Construct the builder.
Args:
args: Parsed arguments from ArgumentParser
"""
self.args = args
# Set jobs to number of cpus unless explicitly set
self.jobs = self.args.jobs
if not self.jobs:
self.jobs = multiprocessing.cpu_count()
print("Number of jobs = {}".format(self.jobs))
# Normalize bootstrap dir and make sure it exists
self.bootstrap_dir = os.path.abspath(self.args.bootstrap_dir)
os.makedirs(self.bootstrap_dir, exist_ok=True)
# Output and platform directories are based on bootstrap
self.output_dir = os.path.join(self.bootstrap_dir, 'output')
self.platform_dir = os.path.join(self.bootstrap_dir, 'staging')
self.sysroot = self.args.sysroot
self.libdir = self.args.libdir
self.install_dir = os.path.join(self.output_dir, 'install')
# If default target isn't set, build everything
self.target = 'all'
if hasattr(self.args, 'target') and self.args.target:
self.target = self.args.target
target_use = self.args.use if self.args.use else []
# Unless set, always build test code
if not self.args.notest:
target_use.append('test')
self.use = UseFlags(target_use)
# Validate platform directory
assert os.path.isdir(self.platform_dir), 'Platform dir does not exist'
assert os.path.isfile(os.path.join(self.platform_dir, '.gn')), 'Platform dir does not have .gn at root'
# Make sure output directory exists (or create it)
os.makedirs(self.output_dir, exist_ok=True)
# Set some default attributes
self.libbase_ver = None
self.configure_environ()
def _generate_rustflags(self):
""" Rustflags to include for the build.
"""
rust_flags = [
'-L',
'{}/out/Default'.format(self.output_dir),
'-C',
'link-arg=-Wl,--allow-multiple-definition',
]
return ' '.join(rust_flags)
def configure_environ(self):
""" Configure environment variables for GN and Cargo.
"""
self.env = os.environ.copy()
# Make sure cargo home dir exists and has a bin directory
cargo_home = os.path.join(self.output_dir, 'cargo_home')
os.makedirs(cargo_home, exist_ok=True)
os.makedirs(os.path.join(cargo_home, 'bin'), exist_ok=True)
# Configure Rust env variables
self.env['CARGO_TARGET_DIR'] = self.output_dir
self.env['CARGO_HOME'] = os.path.join(self.output_dir, 'cargo_home')
self.env['RUSTFLAGS'] = self._generate_rustflags()
self.env['CXX_ROOT_PATH'] = os.path.join(self.platform_dir, 'bt')
def run_command(self, target, args, cwd=None, env=None):
""" Run command and stream the output.
"""
# Set some defaults
if not cwd:
cwd = self.platform_dir
if not env:
env = self.env
log_file = os.path.join(self.output_dir, '{}.log'.format(target))
with open(log_file, 'wb') as lf:
rc = 0
process = subprocess.Popen(args, cwd=cwd, env=env, stdout=subprocess.PIPE)
while True:
line = process.stdout.readline()
print(line.decode('utf-8'), end="")
lf.write(line)
if not line:
rc = process.poll()
if rc is not None:
break
time.sleep(0.1)
if rc != 0:
raise Exception("Return code is {}".format(rc))
def _get_basever(self):
if self.libbase_ver:
return self.libbase_ver
self.libbase_ver = os.environ.get('BASE_VER', '')
if not self.libbase_ver:
base_file = os.path.join(self.sysroot, 'usr/share/libchrome/BASE_VER')
try:
with open(base_file, 'r') as f:
self.libbase_ver = f.read().strip('\n')
except:
self.libbase_ver = 'NOT-INSTALLED'
return self.libbase_ver
def _gn_default_output(self):
return os.path.join(self.output_dir, 'out/Default')
def _gn_configure(self):
""" Configure all required parameters for platform2.
Mostly copied from //common-mk/platform2.py
"""
clang = not self.args.no_clang
def to_gn_string(s):
return '"%s"' % s.replace('"', '\\"')
def to_gn_list(strs):
return '[%s]' % ','.join([to_gn_string(s) for s in strs])
def to_gn_args_args(gn_args):
for k, v in gn_args.items():
if isinstance(v, bool):
v = str(v).lower()
elif isinstance(v, list):
v = to_gn_list(v)
elif isinstance(v, six.string_types):
v = to_gn_string(v)
else:
raise AssertionError('Unexpected %s, %r=%r' % (type(v), k, v))
yield '%s=%s' % (k.replace('-', '_'), v)
gn_args = {
'platform_subdir': 'bt',
'cc': 'clang' if clang else 'gcc',
'cxx': 'clang++' if clang else 'g++',
'ar': 'llvm-ar' if clang else 'ar',
'pkg-config': 'pkg-config',
'clang_cc': clang,
'clang_cxx': clang,
'OS': 'linux',
'sysroot': self.sysroot,
'libdir': os.path.join(self.sysroot, self.libdir),
'build_root': self.output_dir,
'platform2_root': self.platform_dir,
'libbase_ver': self._get_basever(),
'enable_exceptions': os.environ.get('CXXEXCEPTIONS', 0) == '1',
'external_cflags': [],
'external_cxxflags': ["-DNDEBUG"],
'enable_werror': False,
}
if clang:
# Make sure to mark the clang use flag as true
self.use.set_flag('clang', True)
gn_args['external_cxxflags'] += ['-I/usr/include/']
gn_args_args = list(to_gn_args_args(gn_args))
use_args = ['%s=%s' % (k, str(v).lower()) for k, v in self.use.flags.items()]
gn_args_args += ['use={%s}' % (' '.join(use_args))]
gn_args = [
'gn',
'gen',
]
if self.args.verbose:
gn_args.append('-v')
gn_args += [
'--root=%s' % self.platform_dir,
'--args=%s' % ' '.join(gn_args_args),
self._gn_default_output(),
]
if 'PKG_CONFIG_PATH' in self.env:
print('DEBUG: PKG_CONFIG_PATH is', self.env['PKG_CONFIG_PATH'])
self.run_command('configure', gn_args)
def _gn_build(self, target):
""" Generate the ninja command for the target and run it.
"""
args = ['%s:%s' % ('bt', target)]
ninja_args = ['ninja', '-C', self._gn_default_output()]
if self.jobs:
ninja_args += ['-j', str(self.jobs)]
ninja_args += args
if self.args.verbose:
ninja_args.append('-v')
self.run_command('build', ninja_args)
def _rust_configure(self):
""" Generate config file at cargo_home so we use vendored crates.
"""
template = """
[source.systembt]
directory = "{}/external/rust/vendor"
[source.crates-io]
replace-with = "systembt"
local-registry = "/nonexistent"
"""
if not self.args.no_vendored_rust:
contents = template.format(self.platform_dir)
with open(os.path.join(self.env['CARGO_HOME'], 'config'), 'w') as f:
f.write(contents)
def _rust_build(self):
""" Run `cargo build` from platform2/bt directory.
"""
self.run_command('rust', ['cargo', 'build'], cwd=os.path.join(self.platform_dir, 'bt'), env=self.env)
def _target_prepare(self):
""" Target to prepare the output directory for building.
This runs gn gen to generate all rquired files and set up the Rust
config properly. This will be run
"""
self._gn_configure()
self._rust_configure()
def _target_tools(self):
""" Build the tools target in an already prepared environment.
"""
self._gn_build('tools')
# Also copy bluetooth_packetgen to CARGO_HOME so it's available
shutil.copy(
os.path.join(self._gn_default_output(), 'bluetooth_packetgen'), os.path.join(self.env['CARGO_HOME'], 'bin'))
def _target_docs(self):
"""Build the Rust docs."""
self.run_command('docs', ['cargo', 'doc'], cwd=os.path.join(self.platform_dir, 'bt'), env=self.env)
def _target_rust(self):
""" Build rust artifacts in an already prepared environment.
"""
self._rust_build()
def _target_main(self):
""" Build the main GN artifacts in an already prepared environment.
"""
self._gn_build('all')
def _target_test(self):
""" Runs the host tests.
"""
# Rust tests first
rust_test_cmd = ['cargo', 'test']
if self.args.test_name:
rust_test_cmd = rust_test_cmd + [self.args.test_name]
self.run_command('test', rust_test_cmd, cwd=os.path.join(self.platform_dir, 'bt'), env=self.env)
# Host tests second based on host test list
for t in HOST_TESTS:
self.run_command(
'test', [os.path.join(self.output_dir, 'out/Default', t)],
cwd=os.path.join(self.output_dir),
env=self.env)
def _target_install(self):
""" Installs files required to run Floss to install directory.
"""
# First make the install directory
prefix = self.install_dir
os.makedirs(prefix, exist_ok=True)
# Next save the cwd and change to install directory
last_cwd = os.getcwd()
os.chdir(prefix)
bindir = os.path.join(self.output_dir, 'debug')
srcdir = os.path.dirname(__file__)
install_map = [
{
'src': os.path.join(bindir, 'btadapterd'),
'dst': 'usr/libexec/bluetooth/btadapterd',
'strip': True
},
{
'src': os.path.join(bindir, 'btmanagerd'),
'dst': 'usr/libexec/bluetooth/btmanagerd',
'strip': True
},
{
'src': os.path.join(bindir, 'btclient'),
'dst': 'usr/local/bin/btclient',
'strip': True
},
]
for v in install_map:
src, partial_dst, strip = (v['src'], v['dst'], v['strip'])
dst = os.path.join(prefix, partial_dst)
# Create dst directory first and copy file there
os.makedirs(os.path.dirname(dst), exist_ok=True)
print('Installing {}'.format(dst))
shutil.copy(src, dst)
# Binary should be marked for strip and no-strip option shouldn't be
# set. No-strip is useful while debugging.
if strip and not self.args.no_strip:
self.run_command('install', ['llvm-strip', dst])
# Put all files into a tar.gz for easier installation
tar_location = os.path.join(prefix, 'floss.tar.gz')
with tarfile.open(tar_location, 'w:gz') as tar:
for v in install_map:
tar.add(v['dst'])
print('Tarball created at {}'.format(tar_location))
def _target_clean(self):
""" Delete the output directory entirely.
"""
shutil.rmtree(self.output_dir)
# Remove Cargo.lock that may have become generated
try:
os.remove(os.path.join(self.platform_dir, 'bt', 'Cargo.lock'))
except FileNotFoundError:
pass
def _target_all(self):
""" Build all common targets (skipping doc, test, and clean).
"""
self._target_prepare()
self._target_tools()
self._target_main()
self._target_rust()
def build(self):
""" Builds according to self.target
"""
print('Building target ', self.target)
# Validate that the target is valid
if self.target not in VALID_TARGETS:
print('Target {} is not valid. Must be in {}', self.target, VALID_TARGETS)
return
if self.target == 'prepare':
self._target_prepare()
elif self.target == 'tools':
self._target_tools()
elif self.target == 'rust':
self._target_rust()
elif self.target == 'docs':
self._target_docs()
elif self.target == 'main':
self._target_main()
elif self.target == 'test':
self._target_test()
elif self.target == 'clean':
self._target_clean()
elif self.target == 'install':
self._target_install()
elif self.target == 'all':
self._target_all()
class Bootstrap():
def __init__(self, base_dir, bt_dir):
""" Construct bootstrapper.
Args:
base_dir: Where to stage everything.
bt_dir: Where bluetooth source is kept (will be symlinked)
"""
self.base_dir = os.path.abspath(base_dir)
self.bt_dir = os.path.abspath(bt_dir)
# Create base directory if it doesn't already exist
os.makedirs(self.base_dir, exist_ok=True)
if not os.path.isdir(self.bt_dir):
raise Exception('{} is not a valid directory'.format(self.bt_dir))
self.git_dir = os.path.join(self.base_dir, 'repos')
self.staging_dir = os.path.join(self.base_dir, 'staging')
self.output_dir = os.path.join(self.base_dir, 'output')
self.external_dir = os.path.join(self.base_dir, 'staging', 'external')
self.dir_setup_complete = os.path.join(self.base_dir, '.setup-complete')
def _update_platform2(self):
"""Updates repositories used for build."""
for repo in BOOTSTRAP_GIT_REPOS.keys():
cwd = os.path.join(self.git_dir, repo)
subprocess.check_call(['git', 'pull'], cwd=cwd)
def _setup_platform2(self):
""" Set up platform2.
This will check out all the git repos and symlink everything correctly.
"""
# Create all directories we will need to use
for dirpath in [self.git_dir, self.staging_dir, self.output_dir, self.external_dir]:
os.makedirs(dirpath, exist_ok=True)
# If already set up, only update platform2
if os.path.isfile(self.dir_setup_complete):
print('{} already set-up. Updating instead.'.format(self.base_dir))
self._update_platform2()
else:
# Check out all repos in git directory
for repo in BOOTSTRAP_GIT_REPOS.values():
subprocess.check_call(['git', 'clone', repo], cwd=self.git_dir)
# Symlink things
symlinks = [
(os.path.join(self.git_dir, 'platform2', 'common-mk'), os.path.join(self.staging_dir, 'common-mk')),
(os.path.join(self.git_dir, 'platform2', '.gn'), os.path.join(self.staging_dir, '.gn')),
(os.path.join(self.bt_dir), os.path.join(self.staging_dir, 'bt')),
(os.path.join(self.git_dir, 'rust_crates'), os.path.join(self.external_dir, 'rust')),
(os.path.join(self.git_dir, 'proto_logging'), os.path.join(self.external_dir, 'proto_logging')),
]
# Create symlinks
for pairs in symlinks:
(src, dst) = pairs
try:
os.unlink(dst)
except Exception as e:
print(e)
os.symlink(src, dst)
# Write to setup complete file so we don't repeat this step
with open(self.dir_setup_complete, 'w') as f:
f.write('Setup complete.')
def _pretty_print_install(self, install_cmd, packages, line_limit=80):
""" Pretty print an install command.
Args:
install_cmd: Prefixed install command.
packages: Enumerate packages and append them to install command.
line_limit: Number of characters per line.
Return:
Array of lines to join and print.
"""
install = [install_cmd]
line = ' '
# Remainder needed = space + len(pkg) + space + \
# Assuming 80 character lines, that's 80 - 3 = 77
line_limit = line_limit - 3
for pkg in packages:
if len(line) + len(pkg) < line_limit:
line = '{}{} '.format(line, pkg)
else:
install.append(line)
line = ' {} '.format(pkg)
if len(line) > 0:
install.append(line)
return install
def _check_package_installed(self, package, cmd, predicate):
"""Check that the given package is installed.
Args:
package: Check that this package is installed.
cmd: Command prefix to check if installed (package appended to end)
predicate: Function/lambda to check if package is installed based
on output. Takes string output and returns boolean.
Return:
True if package is installed.
"""
try:
output = subprocess.check_output(cmd + [package], stderr=subprocess.STDOUT)
is_installed = predicate(output.decode('utf-8'))
print(' {} is {}'.format(package, 'installed' if is_installed else 'missing'))
return is_installed
except Exception as e:
print(e)
return False
def _get_command_output(self, cmd):
"""Runs the command and gets the output.
Args:
cmd: Command to run.
Return:
Tuple (Success, Output). Success represents if the command ran ok.
"""
try:
output = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
return (True, output.decode('utf-8').split('\n'))
except Exception as e:
print(e)
return (False, "")
def _print_missing_packages(self):
"""Print any missing packages found via apt.
This will find any missing packages necessary for build using apt and
print it out as an apt-get install printf.
"""
print('Checking for any missing packages...')
(success, output) = self._get_command_output(APT_PKG_LIST)
if not success:
raise Exception("Could not query apt for packages.")
packages_installed = {}
for line in output:
if 'installed' in line:
split = line.split('/', 2)
packages_installed[split[0]] = True
need_packages = []
for pkg in REQUIRED_APT_PACKAGES:
if pkg not in packages_installed:
need_packages.append(pkg)
# No packages need to be installed
if len(need_packages) == 0:
print('+ All required packages are installed')
return
install = self._pretty_print_install('sudo apt-get install', need_packages)
# Print all lines so they can be run in cmdline
print('Missing system packages. Run the following command: ')
print(' \\\n'.join(install))
def _print_missing_rust_packages(self):
"""Print any missing packages found via cargo.
This will find any missing packages necessary for build using cargo and
print it out as a cargo-install printf.
"""
print('Checking for any missing cargo packages...')
(success, output) = self._get_command_output(CARGO_PKG_LIST)
if not success:
raise Exception("Could not query cargo for packages.")
packages_installed = {}
for line in output:
# Cargo installed packages have this format
# <crate name> <version>:
# <binary name>
# We only care about the crates themselves
if ':' not in line:
continue
split = line.split(' ', 2)
packages_installed[split[0]] = True
need_packages = []
for pkg in REQUIRED_CARGO_PACKAGES:
if pkg not in packages_installed:
need_packages.append(pkg)
# No packages to be installed
if len(need_packages) == 0:
print('+ All required cargo packages are installed')
return
install = self._pretty_print_install('cargo install', need_packages)
print('Missing cargo packages. Run the following command: ')
print(' \\\n'.join(install))
def bootstrap(self):
""" Bootstrap the Linux build."""
self._setup_platform2()
self._print_missing_packages()
self._print_missing_rust_packages()
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Simple build for host.')
parser.add_argument(
'--bootstrap-dir', help='Directory to run bootstrap on (or was previously run on).', default="~/.floss")
parser.add_argument(
'--run-bootstrap',
help='Run bootstrap code to verify build env is ok to build.',
default=False,
action='store_true')
parser.add_argument('--no-clang', help='Use clang compiler.', default=False, action='store_true')
parser.add_argument(
'--no-strip', help='Skip stripping binaries during install.', default=False, action='store_true')
parser.add_argument('--use', help='Set a specific use flag.')
parser.add_argument('--notest', help='Don\'t compile test code.', default=False, action='store_true')
parser.add_argument('--test-name', help='Run test with this string in the name.', default=None)
parser.add_argument('--target', help='Run specific build target')
parser.add_argument('--sysroot', help='Set a specific sysroot path', default='/')
parser.add_argument('--libdir', help='Libdir - default = usr/lib', default='usr/lib')
parser.add_argument('--jobs', help='Number of jobs to run', default=0, type=int)
parser.add_argument(
'--no-vendored-rust', help='Do not use vendored rust crates', default=False, action='store_true')
parser.add_argument('--verbose', help='Verbose logs for build.')
args = parser.parse_args()
# Make sure we get absolute path + expanded path for bootstrap directory
args.bootstrap_dir = os.path.abspath(os.path.expanduser(args.bootstrap_dir))
if args.run_bootstrap:
bootstrap = Bootstrap(args.bootstrap_dir, os.path.dirname(__file__))
bootstrap.bootstrap()
else:
build = HostBuild(args)
build.build()