# Copyright (C) 2020 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. """Splits a manifest to the minimum set of projects needed to build the targets. Usage: manifest_split [options] targets targets: Space-separated list of targets that should be buildable using the split manifest. options: --manifest Path to the repo manifest to split. [Required] --split-manifest Path to write the resulting split manifest. [Required] --config Optional path(s) to a config XML file containing projects to add or remove. See default_config.xml for an example. This flag can be passed more than once to use multiple config files. Sample file my_config.xml: --ignore-default-config If provided, don't include default_config.xml. --installed-prebuilt Specify the directory containing an installed prebuilt Android.bp file. Supply this option zero or more times, once for each installed prebuilt directory. --repo-list Optional path to the output of the 'repo list' command. Used if the output of 'repo list' needs pre-processing before being used by this tool. --ninja-build Optional path to the combined-.ninja file found in an out dir. If not provided, the default file is used based on the lunch environment. --ninja-binary Optional path to the ninja binary. Uses the standard binary by default. --module-info Optional path to the module-info.json file found in an out dir. If not provided, the default file is used based on the lunch environment. --skip-module-info If provided, skip parsing module-info.json for direct and adjacent dependencies. Overrides --module-info option. --kati-stamp Optional path to the .kati_stamp file found in an out dir. If not provided, the default file is used based on the lunch environment. --skip-kati If provided, skip Kati makefiles projects. Overrides --kati-stamp option. --overlay Optional path(s) to treat as overlays when parsing the kati stamp file and scanning for makefiles. See the tools/treble/build/sandbox directory for more info about overlays. This flag can be passed more than once. --debug-file If provided, debug info will be written to a JSON file at this path. -h (--help) Display this usage message and exit. """ from __future__ import print_function import getopt import json import logging import os import pkgutil import re import subprocess import sys import tempfile from typing import Dict, List, Pattern, Set, Tuple import xml.etree.ElementTree as ET import dataclasses logging.basicConfig( stream=sys.stdout, level=logging.INFO, format="%(asctime)s - %(filename)s - %(levelname)-8s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S") logger = logging.getLogger(os.path.basename(__file__)) # Projects determined to be needed despite the dependency not being visible # to ninja. DEFAULT_CONFIG_XML = "default_config.xml" # Pattern that matches a java dependency. _JAVA_LIB_PATTERN = re.compile( # pylint: disable=line-too-long '^out/target/common/obj/JAVA_LIBRARIES/(.+)_intermediates/classes-header.jar$' ) @dataclasses.dataclass class PathMappingConfig: pattern: Pattern[str] sub: str @dataclasses.dataclass class ManifestSplitConfig: """Holds the configuration for the split manifest tool. Attributes: remove_projects: A Dict of project name to the config file that specified this project, for projects that should be removed from the resulting manifest. add_projects: A Dict of project name to the config file that specified this project, for projects that should be added to the resulting manifest. path_mappings: A list of PathMappingConfigs to modify a path in the build sandbox to the path in the manifest. """ remove_projects: Dict[str, str] add_projects: Dict[str, str] path_mappings: List[PathMappingConfig] @classmethod def from_config_files(cls, config_files: List[str]): """Reads from a list of config XML files. Args: config_files: A list of config XML filenames. Returns: A ManifestSplitConfig from the files. """ remove_projects: Dict[str, str] = {} add_projects: Dict[str, str] = {} path_mappings = [] for config_file in config_files: root = ET.parse(config_file).getroot() remove_projects.update({ c.attrib["name"]: config_file for c in root.findall("remove_project") }) add_projects.update( {c.attrib["name"]: config_file for c in root.findall("add_project")}) path_mappings.extend([ PathMappingConfig( re.compile(child.attrib["pattern"]), child.attrib["sub"]) for child in root.findall("path_mapping") ]) return cls(remove_projects, add_projects, path_mappings) def get_repo_projects(repo_list_file, manifest, path_mappings): """Returns a dict of { project path : project name } using the manifest. The path_mappings stop on the first match mapping. If the mapping results in an empty string, that entry is removed. Args: repo_list_file: An optional filename to read instead of parsing the manifest. manifest: The manifest object to scan for projects. path_mappings: A list of PathMappingConfigs to modify a path in the build sandbox to the path in the manifest. """ repo_list = [] if repo_list_file: with open(repo_list_file) as repo_list_lines: repo_list = [line.strip().split(" : ") for line in repo_list_lines if line.strip()] else: root = manifest.getroot() repo_list = [(p.get("path", p.get("name")), p.get("name")) for p in root.findall("project")] repo_dict = {} for entry in repo_list: path, project = entry for mapping in path_mappings: if mapping.pattern.fullmatch(path): path = mapping.pattern.sub(mapping.sub, path) break # If the resulting path mapping is empty, then don't add entry if path: repo_dict[path] = project return repo_dict class ModuleInfo: """Contains various mappings to/from module/project""" def __init__(self, module_info_file, repo_projects): """Initialize a module info instance. Builds various maps related to platform build system modules and how they relate to each other and projects. Args: module_info_file: The path to a module-info.json file from a build. repo_projects: The output of the get_repo_projects function. Raises: ValueError: A module from module-info.json belongs to a path not known by the repo projects output. """ # Maps a project to the set of modules it contains. self.project_modules = {} # Maps a module to the project that contains it. self.module_project = {} # Maps a module to its class. self.module_class = {} # Maps a module to modules it depends on. self.module_deps = {} with open(module_info_file) as module_info_file: module_info = json.load(module_info_file) def module_has_valid_path(module): return ("path" in module_info[module] and module_info[module]["path"] and not module_info[module]["path"][0].startswith("out/")) module_paths = { module: module_info[module]["path"][0] for module in module_info if module_has_valid_path(module) } module_project_paths = { module: scan_repo_projects(repo_projects, module_paths[module]) for module in module_paths } for module, project_path in module_project_paths.items(): if not project_path: raise ValueError("Unknown module path for module %s: %s" % (module, module_info[module])) repo_project = repo_projects[project_path] self.project_modules.setdefault(repo_project, set()).add(module) self.module_project[module] = repo_project def dep_from_raw_dep(raw_dep): match = re.search(_JAVA_LIB_PATTERN, raw_dep) return match.group(1) if match else raw_dep def deps_from_raw_deps(raw_deps): return [dep_from_raw_dep(raw_dep) for raw_dep in raw_deps] self.module_class = { module: module_info[module]["class"][0] for module in module_info } self.module_deps = { module: deps_from_raw_deps(module_info[module]["dependencies"]) for module in module_info } def get_ninja_inputs(ninja_binary, ninja_build_file, modules): """Returns the set of input file path strings for the given modules. Uses the `ninja -t inputs` tool. Args: ninja_binary: The path to a ninja binary. ninja_build_file: The path to a .ninja file from a build. modules: The list of modules to scan for inputs. """ inputs = set() NINJA_SHARD_LIMIT = 20000 for i in range(0, len(modules), NINJA_SHARD_LIMIT): modules_shard = modules[i:i + NINJA_SHARD_LIMIT] inputs = inputs.union(set( subprocess.check_output([ ninja_binary, "-f", ninja_build_file, "-t", "inputs", "-d", ] + list(modules_shard)).decode().strip("\n").split("\n"))) def input_allowed(path): path = path.strip() if path.endswith("TEST_MAPPING") and "test_mapping" not in modules: # Exclude projects that are only needed for TEST_MAPPING files, unless the # user is asking to build 'test_mapping'. return False if path.endswith("MODULE_LICENSE_GPL"): # Exclude projects that are included only due to having a # MODULE_LICENSE_GPL file, if no other inputs from that project are used. return False return path return {path.strip() for path in inputs if input_allowed(path)} def get_kati_makefiles(kati_stamp_file, overlays): """Returns the set of makefile paths from the kati stamp file. Uses the ckati_stamp_dump prebuilt binary. Also includes symlink sources in the resulting set for any makefiles that are symlinks. Args: kati_stamp_file: The path to a .kati_stamp file from a build. overlays: A list of paths to treat as overlays when parsing the kati stamp file. """ # Get a set of all makefiles that were parsed by Kati during the build. makefiles = set( subprocess.check_output([ "prebuilts/build-tools/linux-x86/bin/ckati_stamp_dump", "--files", kati_stamp_file, ]).decode().strip("\n").split("\n")) def is_product_makefile(makefile): """Returns True if the makefile path meets certain criteria.""" banned_prefixes = [ "out/", # Ignore product makefiles for sample AOSP boards. "device/amlogic", "device/generic", "device/google", "device/linaro", "device/sample", ] banned_suffixes = [ # All Android.mk files in the source are always parsed by Kati, # so including them here would bring in lots of unnecessary projects. "Android.mk", # The ckati stamp file always includes a line for the ckati bin at # the beginnning. "bin/ckati", ] return (all([not makefile.startswith(p) for p in banned_prefixes]) and all([not makefile.endswith(s) for s in banned_suffixes])) # Limit the makefiles to only product makefiles. product_makefiles = { os.path.normpath(path) for path in makefiles if is_product_makefile(path) } def strip_overlay(makefile): """Remove any overlays from a makefile path.""" for overlay in overlays: if makefile.startswith(overlay): return makefile[len(overlay):] return makefile makefiles_and_symlinks = set() for makefile in product_makefiles: # Search for the makefile, possibly scanning overlays as well. for overlay in [""] + overlays: makefile_with_overlay = os.path.join(overlay, makefile) if os.path.exists(makefile_with_overlay): makefile = makefile_with_overlay break if not os.path.exists(makefile): logger.warning("Unknown kati makefile: %s" % makefile) continue # Ensure the project that contains the makefile is included, as well as # the project that any makefile symlinks point to. makefiles_and_symlinks.add(strip_overlay(makefile)) if os.path.islink(makefile): makefiles_and_symlinks.add( strip_overlay(os.path.relpath(os.path.realpath(makefile)))) return makefiles_and_symlinks def scan_repo_projects(repo_projects, input_path): """Returns the project path of the given input path if it exists. Args: repo_projects: The output of the get_repo_projects function. input_path: The path of an input file used in the build, as given by the ninja inputs tool. Returns: The path string, or None if not found. """ parts = input_path.split("/") for index in reversed(range(0, len(parts))): project_path = os.path.join(*parts[:index + 1]) if project_path in repo_projects: return project_path return None def get_input_projects(repo_projects, inputs): """Returns the collection of project names that contain the given input paths. Args: repo_projects: The output of the get_repo_projects function. inputs: The paths of input files used in the build, as given by the ninja inputs tool. """ input_project_paths = {} for input_path in inputs: if not input_path.startswith("out/") and not input_path.startswith("/"): input_project_paths.setdefault( scan_repo_projects(repo_projects, input_path), []).append(input_path) return { repo_projects[project_path]: inputs for project_path, inputs in input_project_paths.items() if project_path is not None } def update_manifest(manifest, input_projects, remove_projects): """Modifies and returns a manifest ElementTree by modifying its projects. Args: manifest: The manifest object to modify. input_projects: A set of projects that should stay in the manifest. remove_projects: A set of projects that should be removed from the manifest. Projects in this set override input_projects. Returns: The modified manifest object. """ projects_to_keep = input_projects.difference(remove_projects) root = manifest.getroot() for child in root.findall("project"): if child.attrib["name"] not in projects_to_keep: root.remove(child) return manifest @dataclasses.dataclass class DebugInfo: """Simple class to store structured debug info for a project.""" direct_input: bool = False adjacent_input: bool = False deps_input: bool = False kati_makefiles: List[str] = dataclasses.field(default_factory=list) manual_add_config: str = "" manual_remove_config: str = "" def create_split_manifest(targets, manifest_file, split_manifest_file, config_files, repo_list_file, ninja_build_file, ninja_binary, module_info_file, kati_stamp_file, overlays, installed_prebuilts, debug_file): """Creates and writes a split manifest by inspecting build inputs. Args: targets: List of targets that should be buildable using the split manifest. manifest_file: Path to the repo manifest to split. split_manifest_file: Path to write the resulting split manifest. config_files: Paths to a config XML file containing projects to add or remove. See default_config.xml for an example. This flag can be passed more than once to use multiple config files. repo_list_file: Path to the output of the 'repo list' command. ninja_build_file: Path to the combined-.ninja file found in an out dir. ninja_binary: Path to the ninja binary. module_info_file: Path to the module-info.json file found in an out dir. kati_stamp_file: The path to a .kati_stamp file from a build. overlays: A list of paths to treat as overlays when parsing the kati stamp file. installed_prebuilts: A list of paths for which to create "fake" repo entries. These entries allow the tool to recognize modules that installed rather than being sync'd via a manifest. debug_file: If not None, the path to write JSON debug info. """ debug_info = {} config = ManifestSplitConfig.from_config_files(config_files) original_manifest = ET.parse(manifest_file) repo_projects = get_repo_projects(repo_list_file, original_manifest, config.path_mappings) repo_projects.update({ip: ip for ip in installed_prebuilts}) inputs = get_ninja_inputs(ninja_binary, ninja_build_file, targets) input_projects = set(get_input_projects(repo_projects, inputs).keys()) for project in input_projects: debug_info.setdefault(project, DebugInfo()).direct_input = True logger.info( "%s projects needed for Ninja-graph direct dependencies of targets \"%s\"", len(input_projects), " ".join(targets)) if kati_stamp_file: kati_makefiles = get_kati_makefiles(kati_stamp_file, overlays) kati_makefiles_projects = get_input_projects(repo_projects, kati_makefiles) for project, makefiles in kati_makefiles_projects.items(): debug_info.setdefault(project, DebugInfo()).kati_makefiles = makefiles input_projects = input_projects.union(kati_makefiles_projects.keys()) logger.info("%s projects after including Kati makefiles projects.", len(input_projects)) else: logger.info("Kati makefiles projects skipped.") for project, cfile in config.add_projects.items(): debug_info.setdefault(project, DebugInfo()).manual_add_config = cfile for project, cfile in config.remove_projects.items(): debug_info.setdefault(project, DebugInfo()).manual_remove_config = cfile input_projects = input_projects.union(config.add_projects.keys()) logger.info("%s projects after including manual additions.", len(input_projects)) # Remove projects from our set of input projects before adding adjacent # modules, so that no project is added only because of an adjacent # dependency in a to-be-removed project. input_projects = input_projects.difference(config.remove_projects.keys()) # While we still have projects whose modules we haven't checked yet, if module_info_file: module_info = ModuleInfo(module_info_file, repo_projects) checked_projects = set() projects_to_check = input_projects.difference(checked_projects) logger.info("Checking module-info dependencies for direct and adjacent modules...") else: logging.info("Direct and adjacent modules skipped.") projects_to_check = None iteration = 0 while projects_to_check: iteration += 1 # check all modules in each project, modules = [] deps_additions = set() def process_deps(module): for d in module_info.module_deps[module]: if d in module_info.module_class: if module_info.module_class[d] == "HEADER_LIBRARIES": hla = module_info.module_project[d] if hla not in input_projects: deps_additions.add(hla) for project in projects_to_check: checked_projects.add(project) if project not in module_info.project_modules: continue for module in module_info.project_modules[project]: modules.append(module) process_deps(module) for project in deps_additions: debug_info.setdefault(project, DebugInfo()).deps_input = True input_projects = input_projects.union(deps_additions) logger.info( "pass %d - %d projects after including HEADER_LIBRARIES dependencies", iteration, len(input_projects)) # adding those modules' input projects to our list of projects. inputs = get_ninja_inputs(ninja_binary, ninja_build_file, modules) adjacent_module_additions = set( get_input_projects(repo_projects, inputs).keys()) for project in adjacent_module_additions: debug_info.setdefault(project, DebugInfo()).adjacent_input = True input_projects = input_projects.union(adjacent_module_additions) logger.info( "pass %d - %d projects after including adjacent-module Ninja-graph dependencies", iteration, len(input_projects)) projects_to_check = input_projects.difference(checked_projects) logger.info("%s projects - complete", len(input_projects)) split_manifest = update_manifest(original_manifest, input_projects, config.remove_projects.keys()) split_manifest.write(split_manifest_file) if debug_file: with open(debug_file, "w") as debug_fp: logger.info("Writing debug info to %s", debug_file) json.dump( debug_info, fp=debug_fp, sort_keys=True, indent=2, default=lambda info: info.__dict__) def main(argv): try: opts, args = getopt.getopt(argv, "h", [ "help", "debug-file=", "manifest=", "split-manifest=", "config=", "ignore-default-config", "repo-list=", "ninja-build=", "ninja-binary=", "module-info=", "skip-module-info", "kati-stamp=", "skip-kati", "overlay=", "installed-prebuilt=", ]) except getopt.GetoptError as err: print(__doc__, file=sys.stderr) print("**%s**" % str(err), file=sys.stderr) sys.exit(2) debug_file = None manifest_file = None split_manifest_file = None config_files = [] repo_list_file = None ninja_build_file = None module_info_file = None ninja_binary = "prebuilts/build-tools/linux-x86/bin/ninja" kati_stamp_file = None overlays = [] installed_prebuilts = [] ignore_default_config = False skip_kati = False skip_module_info = False for o, a in opts: if o in ("-h", "--help"): print(__doc__, file=sys.stderr) sys.exit() elif o in ("--debug-file"): debug_file = a elif o in ("--manifest"): manifest_file = a elif o in ("--split-manifest"): split_manifest_file = a elif o in ("--config"): config_files.append(a) elif o == "--ignore-default-config": ignore_default_config = True elif o in ("--repo-list"): repo_list_file = a elif o in ("--ninja-build"): ninja_build_file = a elif o in ("--ninja-binary"): ninja_binary = a elif o in ("--module-info"): module_info_file = a elif o == "--skip-module-info": skip_module_info = True elif o in ("--kati-stamp"): kati_stamp_file = a elif o == "--skip-kati": skip_kati = True elif o in ("--overlay"): overlays.append(a) elif o in ("--installed-prebuilt"): installed_prebuilts.append(a) else: assert False, "unknown option \"%s\"" % o if not args: print(__doc__, file=sys.stderr) print("**Missing targets**", file=sys.stderr) sys.exit(2) if not manifest_file: print(__doc__, file=sys.stderr) print("**Missing required flag --manifest**", file=sys.stderr) sys.exit(2) if not split_manifest_file: print(__doc__, file=sys.stderr) print("**Missing required flag --split-manifest**", file=sys.stderr) sys.exit(2) if skip_module_info: if module_info_file: logging.warning("User provided both --skip-module-info and --module-info args. Arg --module-info ignored.") module_info_file = None elif not module_info_file: module_info_file = os.path.join(os.environ["ANDROID_PRODUCT_OUT"], "module-info.json") if skip_kati: if kati_stamp_file: logging.warning("User provided both --skip-kati and --kati-stamp args. Arg --kati-stamp ignored.") kati_stamp_file = None elif not kati_stamp_file: kati_stamp_file = os.path.join( os.environ["ANDROID_BUILD_TOP"], "out", ".kati_stamp-%s" % os.environ["TARGET_PRODUCT"]) if not ninja_build_file: ninja_build_file = os.path.join( os.environ["ANDROID_BUILD_TOP"], "out", "combined-%s.ninja" % os.environ["TARGET_PRODUCT"]) with tempfile.NamedTemporaryFile() as default_config_file: if not ignore_default_config: default_config_file.write(pkgutil.get_data(__name__, DEFAULT_CONFIG_XML)) default_config_file.flush() config_files.insert(0, default_config_file.name) create_split_manifest( targets=args, manifest_file=manifest_file, split_manifest_file=split_manifest_file, config_files=config_files, repo_list_file=repo_list_file, ninja_build_file=ninja_build_file, ninja_binary=ninja_binary, module_info_file=module_info_file, kati_stamp_file=kati_stamp_file, overlays=overlays, installed_prebuilts=installed_prebuilts, debug_file=debug_file) if __name__ == "__main__": main(sys.argv[1:])