blob: 89f8dc5fee39a3b109c45293a3000e24b480adc5 [file] [log] [blame]
# 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>
Path to the repo manifest to split. [Required]
--split-manifest <path>
Path to write the resulting split manifest. [Required]
--config <path>
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:
<config>
<add_project name="vendor/my/needed/project" />
<remove_project name="vendor/my/unused/project" />
</config>
--repo-list <path>
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 <path>
Optional path to the combined-<target>.ninja file found in an out dir.
If not provided, the default file is used based on the lunch environment.
--ninja-binary <path>
Optional path to the ninja binary. Uses the standard binary by default.
--module-info <path>
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 <path>
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 <path>
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-<target>.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:])