# 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: --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. --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. --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 Print debug messages. -h (--help) Display this usage message and exit. """ from __future__ import print_function import getopt import hashlib import json import logging import os import pkg_resources import subprocess import sys import xml.etree.ElementTree as ET 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_PATH = pkg_resources.resource_filename(__name__, "default_config.xml") def read_config(config_file): """Reads a config XML file to find extra projects to add or remove. Args: config_file: The filename of the config XML. Returns: A tuple of (set of remove_projects, set of add_projects) from the config. """ root = ET.parse(config_file).getroot() remove_projects = set( [child.attrib["name"] for child in root.findall("remove_project")]) add_projects = set( [child.attrib["name"] for child in root.findall("add_project")]) return remove_projects, add_projects def get_repo_projects(repo_list_file): """Returns a dict of { project path : project name } using 'repo list'. Args: repo_list_file: An optional filename to read instead of calling the repo list command. """ repo_list = [] if repo_list_file: with open(repo_list_file) as repo_list_lines: repo_list = [line.strip() for line in repo_list_lines if line.strip()] else: repo_list = subprocess.check_output([ "repo", "list", ]).decode().strip("\n").split("\n") return dict([entry.split(" : ") for entry in repo_list]) def get_module_info(module_info_file, repo_projects): """Returns a dict of { project name : set of modules } in each project. 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. """ project_modules = {} 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])) project_modules.setdefault(repo_projects[project_path], set()).add(module) return project_modules 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 set of modules to scan for inputs. """ inputs = set( subprocess.check_output([ ninja_binary, "-f", ninja_build_file, "-t", "inputs", "-d", ] + list(modules)).decode().strip("\n").split("\n")) return {path.strip() for path in inputs} 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 set 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 = [ scan_repo_projects(repo_projects, input_path) for input_path in inputs if (not input_path.startswith("out/") and not input_path.startswith("/")) ] return { repo_projects[project_path] for project_path in input_project_paths 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 def create_manifest_sha1_element(manifest, name): """Creates and returns an ElementTree 'hash' Element using a sha1 hash. Args: manifest: The manifest ElementTree to hash. name: The name string to give this element. Returns: The ElementTree 'hash' Element. """ sha1_element = ET.Element("hash") sha1_element.set("type", "sha1") sha1_element.set("name", name) sha1_element.set("value", hashlib.sha1(ET.tostring(manifest.getroot())).hexdigest()) return sha1_element 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): """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. """ remove_projects = set() add_projects = set() for config_file in config_files: config_remove_projects, config_add_projects = read_config(config_file) remove_projects = remove_projects.union(config_remove_projects) add_projects = add_projects.union(config_add_projects) repo_projects = get_repo_projects(repo_list_file) module_info = get_module_info(module_info_file, repo_projects) inputs = get_ninja_inputs(ninja_binary, ninja_build_file, targets) input_projects = get_input_projects(repo_projects, inputs) if logger.isEnabledFor(logging.DEBUG): for project in sorted(input_projects): logger.debug("Direct dependency: %s", project) logger.info("%s projects needed for targets \"%s\"", len(input_projects), " ".join(targets)) kati_makefiles = get_kati_makefiles(kati_stamp_file, overlays) kati_makefiles_projects = get_input_projects(repo_projects, kati_makefiles) if logger.isEnabledFor(logging.DEBUG): for project in sorted(kati_makefiles_projects.difference(input_projects)): logger.debug("Kati makefile dependency: %s", project) input_projects = input_projects.union(kati_makefiles_projects) logger.info("%s projects after including Kati makefiles projects.", len(input_projects)) if logger.isEnabledFor(logging.DEBUG): manual_projects = add_projects.difference(input_projects) for project in sorted(manual_projects): logger.debug("Manual inclusion: %s", project) input_projects = input_projects.union(add_projects) 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(remove_projects) # While we still have projects whose modules we haven't checked yet, checked_projects = set() projects_to_check = input_projects.difference(checked_projects) while projects_to_check: # check all modules in each project, modules = [] for project in projects_to_check: checked_projects.add(project) if project not in module_info: continue modules += module_info[project] # adding those modules' input projects to our list of projects. inputs = get_ninja_inputs(ninja_binary, ninja_build_file, modules) adjacent_module_additions = get_input_projects(repo_projects, inputs) if logger.isEnabledFor(logging.DEBUG): for project in sorted( adjacent_module_additions.difference(input_projects)): logger.debug("Adjacent module dependency: %s", project) input_projects = input_projects.union(adjacent_module_additions) logger.info("%s total projects so far.", len(input_projects)) projects_to_check = input_projects.difference(checked_projects) original_manifest = ET.parse(manifest_file) original_sha1 = create_manifest_sha1_element(original_manifest, "original") split_manifest = update_manifest(original_manifest, input_projects, remove_projects) split_manifest.getroot().append(original_sha1) split_manifest.getroot().append( create_manifest_sha1_element(split_manifest, "self")) split_manifest.write(split_manifest_file) def main(argv): try: opts, args = getopt.getopt(argv, "h", [ "help", "debug", "manifest=", "split-manifest=", "config=", "repo-list=", "ninja-build=", "ninja-binary=", "module-info=", "kati-stamp=", "overlay=", ]) except getopt.GetoptError as err: print(__doc__, file=sys.stderr) print("**%s**" % str(err), file=sys.stderr) sys.exit(2) manifest_file = None split_manifest_file = None config_files = [DEFAULT_CONFIG_PATH] repo_list_file = None ninja_build_file = None module_info_file = None ninja_binary = "ninja" kati_stamp_file = None overlays = [] for o, a in opts: if o in ("-h", "--help"): print(__doc__, file=sys.stderr) sys.exit() elif o in ("--debug"): logger.setLevel(logging.DEBUG) 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 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 in ("--kati-stamp"): kati_stamp_file = a elif o in ("--overlay"): overlays.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 not module_info_file: module_info_file = os.path.join(os.environ["ANDROID_PRODUCT_OUT"], "module-info.json") if 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"]) 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) if __name__ == "__main__": main(sys.argv[1:])