#!/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 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 = [ 'prepare', # Prepare the output directory (gn gen + rust setup) 'tools', # Build the host tools (i.e. packetgen) 'rust', # Build only the rust components + copy artifacts to output dir 'main', # Build the main C++ codebase 'test', # Run the unit tests 'clean', # Clean up output directory 'all', # All targets except test and clean ] # 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++-dev', 'libdbus-1-dev', 'libevent-dev', 'libevent-dev', 'libflatbuffers-dev', 'libflatbuffers1', 'libgl1-mesa-dev', 'libglib2.0-dev', 'liblz4-tool', 'libncurses5', 'libnss3-dev', 'libprotobuf-dev', 'libre2-9', '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 # 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': [], '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_rust(self): """ Build rust artifacts in an already prepared environment. """ self._rust_build() rust_dir = os.path.join(self._gn_default_output(), 'rust') if os.path.exists(rust_dir): shutil.rmtree(rust_dir) shutil.copytree(os.path.join(self.output_dir, 'debug'), rust_dir) 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 self.run_command('test', ['cargo', 'test'], 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_clean(self): """ Delete the output directory entirely. """ shutil.rmtree(self.output_dir) # Remove Cargo.lock that may have become generated os.remove(os.path.join(self.platform_dir, 'bt', 'Cargo.lock')) def _target_all(self): """ Build all common targets (skipping 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) if self.target == 'prepare': self._target_prepare() elif self.target == 'tools': self._target_tools() elif self.target == 'rust': self._target_rust() elif self.target == 'main': self._target_main() elif self.target == 'test': self._target_test() elif self.target == 'clean': self._target_clean() 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. """ # If already set up, exit early if os.path.isfile(self.dir_setup_complete): print('{} already set-up. Updating instead.'.format(self.base_dir)) self._update_platform2() return # 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) # 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 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 # : # # 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('--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('--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()