1# Copyright (C) 2020 The Android Open Source Project 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14"""Splits a manifest to the minimum set of projects needed to build the targets. 15 16Usage: manifest_split [options] targets 17 18targets: Space-separated list of targets that should be buildable 19 using the split manifest. 20 21options: 22 --manifest <path> 23 Path to the repo manifest to split. [Required] 24 --split-manifest <path> 25 Path to write the resulting split manifest. [Required] 26 --config <path> 27 Optional path(s) to a config XML file containing projects to add or 28 remove. See default_config.xml for an example. This flag can be passed 29 more than once to use multiple config files. 30 Sample file my_config.xml: 31 <config> 32 <add_project name="vendor/my/needed/project" /> 33 <remove_project name="vendor/my/unused/project" /> 34 </config> 35 --repo-list <path> 36 Optional path to the output of the 'repo list' command. Used if the 37 output of 'repo list' needs pre-processing before being used by 38 this tool. 39 --ninja-build <path> 40 Optional path to the combined-<target>.ninja file found in an out dir. 41 If not provided, the default file is used based on the lunch environment. 42 --ninja-binary <path> 43 Optional path to the ninja binary. Uses the standard binary by default. 44 --module-info <path> 45 Optional path to the module-info.json file found in an out dir. 46 If not provided, the default file is used based on the lunch environment. 47 --kati-stamp <path> 48 Optional path to the .kati_stamp file found in an out dir. 49 If not provided, the default file is used based on the lunch environment. 50 --overlay <path> 51 Optional path(s) to treat as overlays when parsing the kati stamp file 52 and scanning for makefiles. See the tools/treble/build/sandbox directory 53 for more info about overlays. This flag can be passed more than once. 54 --debug 55 Print debug messages. 56 -h (--help) 57 Display this usage message and exit. 58""" 59 60from __future__ import print_function 61 62import getopt 63import hashlib 64import json 65import logging 66import os 67import pkg_resources 68import subprocess 69import sys 70import xml.etree.ElementTree as ET 71 72logging.basicConfig( 73 stream=sys.stdout, 74 level=logging.INFO, 75 format="%(asctime)s - %(filename)s - %(levelname)-8s: %(message)s", 76 datefmt="%Y-%m-%d %H:%M:%S") 77logger = logging.getLogger(os.path.basename(__file__)) 78 79# Projects determined to be needed despite the dependency not being visible 80# to ninja. 81DEFAULT_CONFIG_PATH = pkg_resources.resource_filename(__name__, 82 "default_config.xml") 83 84 85def read_config(config_file): 86 """Reads a config XML file to find extra projects to add or remove. 87 88 Args: 89 config_file: The filename of the config XML. 90 91 Returns: 92 A tuple of (set of remove_projects, set of add_projects) from the config. 93 """ 94 root = ET.parse(config_file).getroot() 95 remove_projects = set( 96 [child.attrib["name"] for child in root.findall("remove_project")]) 97 add_projects = set( 98 [child.attrib["name"] for child in root.findall("add_project")]) 99 return remove_projects, add_projects 100 101 102def get_repo_projects(repo_list_file): 103 """Returns a dict of { project path : project name } using 'repo list'. 104 105 Args: 106 repo_list_file: An optional filename to read instead of calling the repo 107 list command. 108 """ 109 repo_list = [] 110 111 if repo_list_file: 112 with open(repo_list_file) as repo_list_lines: 113 repo_list = [line.strip() for line in repo_list_lines if line.strip()] 114 else: 115 repo_list = subprocess.check_output([ 116 "repo", 117 "list", 118 ]).decode().strip("\n").split("\n") 119 return dict([entry.split(" : ") for entry in repo_list]) 120 121 122def get_module_info(module_info_file, repo_projects): 123 """Returns a dict of { project name : set of modules } in each project. 124 125 Args: 126 module_info_file: The path to a module-info.json file from a build. 127 repo_projects: The output of the get_repo_projects function. 128 129 Raises: 130 ValueError: A module from module-info.json belongs to a path not 131 known by the repo projects output. 132 """ 133 project_modules = {} 134 135 with open(module_info_file) as module_info_file: 136 module_info = json.load(module_info_file) 137 138 def module_has_valid_path(module): 139 return ("path" in module_info[module] and module_info[module]["path"] and 140 not module_info[module]["path"][0].startswith("out/")) 141 142 module_paths = { 143 module: module_info[module]["path"][0] 144 for module in module_info 145 if module_has_valid_path(module) 146 } 147 module_project_paths = { 148 module: scan_repo_projects(repo_projects, module_paths[module]) 149 for module in module_paths 150 } 151 152 for module, project_path in module_project_paths.items(): 153 if not project_path: 154 raise ValueError("Unknown module path for module %s: %s" % 155 (module, module_info[module])) 156 project_modules.setdefault(repo_projects[project_path], set()).add(module) 157 return project_modules 158 159 160def get_ninja_inputs(ninja_binary, ninja_build_file, modules): 161 """Returns the set of input file path strings for the given modules. 162 163 Uses the `ninja -t inputs` tool. 164 165 Args: 166 ninja_binary: The path to a ninja binary. 167 ninja_build_file: The path to a .ninja file from a build. 168 modules: The set of modules to scan for inputs. 169 """ 170 inputs = set( 171 subprocess.check_output([ 172 ninja_binary, 173 "-f", 174 ninja_build_file, 175 "-t", 176 "inputs", 177 "-d", 178 ] + list(modules)).decode().strip("\n").split("\n")) 179 return {path.strip() for path in inputs} 180 181 182def get_kati_makefiles(kati_stamp_file, overlays): 183 """Returns the set of makefile paths from the kati stamp file. 184 185 Uses the ckati_stamp_dump prebuilt binary. 186 Also includes symlink sources in the resulting set for any 187 makefiles that are symlinks. 188 189 Args: 190 kati_stamp_file: The path to a .kati_stamp file from a build. 191 overlays: A list of paths to treat as overlays when parsing the kati stamp 192 file. 193 """ 194 # Get a set of all makefiles that were parsed by Kati during the build. 195 makefiles = set( 196 subprocess.check_output([ 197 "prebuilts/build-tools/linux-x86/bin/ckati_stamp_dump", 198 "--files", 199 kati_stamp_file, 200 ]).decode().strip("\n").split("\n")) 201 202 def is_product_makefile(makefile): 203 """Returns True if the makefile path meets certain criteria.""" 204 banned_prefixes = [ 205 "out/", 206 # Ignore product makefiles for sample AOSP boards. 207 "device/amlogic", 208 "device/generic", 209 "device/google", 210 "device/linaro", 211 "device/sample", 212 ] 213 banned_suffixes = [ 214 # All Android.mk files in the source are always parsed by Kati, 215 # so including them here would bring in lots of unnecessary projects. 216 "Android.mk", 217 # The ckati stamp file always includes a line for the ckati bin at 218 # the beginnning. 219 "bin/ckati", 220 ] 221 return (all([not makefile.startswith(p) for p in banned_prefixes]) and 222 all([not makefile.endswith(s) for s in banned_suffixes])) 223 224 # Limit the makefiles to only product makefiles. 225 product_makefiles = { 226 os.path.normpath(path) for path in makefiles if is_product_makefile(path) 227 } 228 229 def strip_overlay(makefile): 230 """Remove any overlays from a makefile path.""" 231 for overlay in overlays: 232 if makefile.startswith(overlay): 233 return makefile[len(overlay):] 234 return makefile 235 236 makefiles_and_symlinks = set() 237 for makefile in product_makefiles: 238 # Search for the makefile, possibly scanning overlays as well. 239 for overlay in [""] + overlays: 240 makefile_with_overlay = os.path.join(overlay, makefile) 241 if os.path.exists(makefile_with_overlay): 242 makefile = makefile_with_overlay 243 break 244 245 if not os.path.exists(makefile): 246 logger.warning("Unknown kati makefile: %s" % makefile) 247 continue 248 249 # Ensure the project that contains the makefile is included, as well as 250 # the project that any makefile symlinks point to. 251 makefiles_and_symlinks.add(strip_overlay(makefile)) 252 if os.path.islink(makefile): 253 makefiles_and_symlinks.add( 254 strip_overlay(os.path.relpath(os.path.realpath(makefile)))) 255 256 return makefiles_and_symlinks 257 258 259def scan_repo_projects(repo_projects, input_path): 260 """Returns the project path of the given input path if it exists. 261 262 Args: 263 repo_projects: The output of the get_repo_projects function. 264 input_path: The path of an input file used in the build, as given by the 265 ninja inputs tool. 266 267 Returns: 268 The path string, or None if not found. 269 """ 270 parts = input_path.split("/") 271 272 for index in reversed(range(0, len(parts))): 273 project_path = os.path.join(*parts[:index + 1]) 274 if project_path in repo_projects: 275 return project_path 276 277 return None 278 279 280def get_input_projects(repo_projects, inputs): 281 """Returns the set of project names that contain the given input paths. 282 283 Args: 284 repo_projects: The output of the get_repo_projects function. 285 inputs: The paths of input files used in the build, as given by the ninja 286 inputs tool. 287 """ 288 input_project_paths = [ 289 scan_repo_projects(repo_projects, input_path) 290 for input_path in inputs 291 if (not input_path.startswith("out/") and not input_path.startswith("/")) 292 ] 293 return { 294 repo_projects[project_path] 295 for project_path in input_project_paths 296 if project_path is not None 297 } 298 299 300def update_manifest(manifest, input_projects, remove_projects): 301 """Modifies and returns a manifest ElementTree by modifying its projects. 302 303 Args: 304 manifest: The manifest object to modify. 305 input_projects: A set of projects that should stay in the manifest. 306 remove_projects: A set of projects that should be removed from the manifest. 307 Projects in this set override input_projects. 308 309 Returns: 310 The modified manifest object. 311 """ 312 projects_to_keep = input_projects.difference(remove_projects) 313 root = manifest.getroot() 314 for child in root.findall("project"): 315 if child.attrib["name"] not in projects_to_keep: 316 root.remove(child) 317 return manifest 318 319 320def create_manifest_sha1_element(manifest, name): 321 """Creates and returns an ElementTree 'hash' Element using a sha1 hash. 322 323 Args: 324 manifest: The manifest ElementTree to hash. 325 name: The name string to give this element. 326 327 Returns: 328 The ElementTree 'hash' Element. 329 """ 330 sha1_element = ET.Element("hash") 331 sha1_element.set("type", "sha1") 332 sha1_element.set("name", name) 333 sha1_element.set("value", 334 hashlib.sha1(ET.tostring(manifest.getroot())).hexdigest()) 335 return sha1_element 336 337 338def create_split_manifest(targets, manifest_file, split_manifest_file, 339 config_files, repo_list_file, ninja_build_file, 340 ninja_binary, module_info_file, kati_stamp_file, 341 overlays): 342 """Creates and writes a split manifest by inspecting build inputs. 343 344 Args: 345 targets: List of targets that should be buildable using the split manifest. 346 manifest_file: Path to the repo manifest to split. 347 split_manifest_file: Path to write the resulting split manifest. 348 config_files: Paths to a config XML file containing projects to add or 349 remove. See default_config.xml for an example. This flag can be passed 350 more than once to use multiple config files. 351 repo_list_file: Path to the output of the 'repo list' command. 352 ninja_build_file: Path to the combined-<target>.ninja file found in an out 353 dir. 354 ninja_binary: Path to the ninja binary. 355 module_info_file: Path to the module-info.json file found in an out dir. 356 kati_stamp_file: The path to a .kati_stamp file from a build. 357 overlays: A list of paths to treat as overlays when parsing the kati stamp 358 file. 359 """ 360 remove_projects = set() 361 add_projects = set() 362 for config_file in config_files: 363 config_remove_projects, config_add_projects = read_config(config_file) 364 remove_projects = remove_projects.union(config_remove_projects) 365 add_projects = add_projects.union(config_add_projects) 366 367 repo_projects = get_repo_projects(repo_list_file) 368 module_info = get_module_info(module_info_file, repo_projects) 369 370 inputs = get_ninja_inputs(ninja_binary, ninja_build_file, targets) 371 input_projects = get_input_projects(repo_projects, inputs) 372 if logger.isEnabledFor(logging.DEBUG): 373 for project in sorted(input_projects): 374 logger.debug("Direct dependency: %s", project) 375 logger.info("%s projects needed for targets \"%s\"", len(input_projects), 376 " ".join(targets)) 377 378 kati_makefiles = get_kati_makefiles(kati_stamp_file, overlays) 379 kati_makefiles_projects = get_input_projects(repo_projects, kati_makefiles) 380 if logger.isEnabledFor(logging.DEBUG): 381 for project in sorted(kati_makefiles_projects.difference(input_projects)): 382 logger.debug("Kati makefile dependency: %s", project) 383 input_projects = input_projects.union(kati_makefiles_projects) 384 logger.info("%s projects after including Kati makefiles projects.", 385 len(input_projects)) 386 387 if logger.isEnabledFor(logging.DEBUG): 388 manual_projects = add_projects.difference(input_projects) 389 for project in sorted(manual_projects): 390 logger.debug("Manual inclusion: %s", project) 391 input_projects = input_projects.union(add_projects) 392 logger.info("%s projects after including manual additions.", 393 len(input_projects)) 394 395 # Remove projects from our set of input projects before adding adjacent 396 # modules, so that no project is added only because of an adjacent 397 # dependency in a to-be-removed project. 398 input_projects = input_projects.difference(remove_projects) 399 400 # While we still have projects whose modules we haven't checked yet, 401 checked_projects = set() 402 projects_to_check = input_projects.difference(checked_projects) 403 while projects_to_check: 404 # check all modules in each project, 405 modules = [] 406 for project in projects_to_check: 407 checked_projects.add(project) 408 if project not in module_info: 409 continue 410 modules += module_info[project] 411 412 # adding those modules' input projects to our list of projects. 413 inputs = get_ninja_inputs(ninja_binary, ninja_build_file, modules) 414 adjacent_module_additions = get_input_projects(repo_projects, inputs) 415 if logger.isEnabledFor(logging.DEBUG): 416 for project in sorted( 417 adjacent_module_additions.difference(input_projects)): 418 logger.debug("Adjacent module dependency: %s", project) 419 input_projects = input_projects.union(adjacent_module_additions) 420 logger.info("%s total projects so far.", len(input_projects)) 421 422 projects_to_check = input_projects.difference(checked_projects) 423 424 original_manifest = ET.parse(manifest_file) 425 original_sha1 = create_manifest_sha1_element(original_manifest, "original") 426 split_manifest = update_manifest(original_manifest, input_projects, 427 remove_projects) 428 split_manifest.getroot().append(original_sha1) 429 split_manifest.getroot().append( 430 create_manifest_sha1_element(split_manifest, "self")) 431 split_manifest.write(split_manifest_file) 432 433 434def main(argv): 435 try: 436 opts, args = getopt.getopt(argv, "h", [ 437 "help", 438 "debug", 439 "manifest=", 440 "split-manifest=", 441 "config=", 442 "repo-list=", 443 "ninja-build=", 444 "ninja-binary=", 445 "module-info=", 446 "kati-stamp=", 447 "overlay=", 448 ]) 449 except getopt.GetoptError as err: 450 print(__doc__, file=sys.stderr) 451 print("**%s**" % str(err), file=sys.stderr) 452 sys.exit(2) 453 454 manifest_file = None 455 split_manifest_file = None 456 config_files = [DEFAULT_CONFIG_PATH] 457 repo_list_file = None 458 ninja_build_file = None 459 module_info_file = None 460 ninja_binary = "ninja" 461 kati_stamp_file = None 462 overlays = [] 463 464 for o, a in opts: 465 if o in ("-h", "--help"): 466 print(__doc__, file=sys.stderr) 467 sys.exit() 468 elif o in ("--debug"): 469 logger.setLevel(logging.DEBUG) 470 elif o in ("--manifest"): 471 manifest_file = a 472 elif o in ("--split-manifest"): 473 split_manifest_file = a 474 elif o in ("--config"): 475 config_files.append(a) 476 elif o in ("--repo-list"): 477 repo_list_file = a 478 elif o in ("--ninja-build"): 479 ninja_build_file = a 480 elif o in ("--ninja-binary"): 481 ninja_binary = a 482 elif o in ("--module-info"): 483 module_info_file = a 484 elif o in ("--kati-stamp"): 485 kati_stamp_file = a 486 elif o in ("--overlay"): 487 overlays.append(a) 488 else: 489 assert False, "unknown option \"%s\"" % o 490 491 if not args: 492 print(__doc__, file=sys.stderr) 493 print("**Missing targets**", file=sys.stderr) 494 sys.exit(2) 495 if not manifest_file: 496 print(__doc__, file=sys.stderr) 497 print("**Missing required flag --manifest**", file=sys.stderr) 498 sys.exit(2) 499 if not split_manifest_file: 500 print(__doc__, file=sys.stderr) 501 print("**Missing required flag --split-manifest**", file=sys.stderr) 502 sys.exit(2) 503 if not module_info_file: 504 module_info_file = os.path.join(os.environ["ANDROID_PRODUCT_OUT"], 505 "module-info.json") 506 if not kati_stamp_file: 507 kati_stamp_file = os.path.join( 508 os.environ["ANDROID_BUILD_TOP"], "out", 509 ".kati_stamp-%s" % os.environ["TARGET_PRODUCT"]) 510 if not ninja_build_file: 511 ninja_build_file = os.path.join( 512 os.environ["ANDROID_BUILD_TOP"], "out", 513 "combined-%s.ninja" % os.environ["TARGET_PRODUCT"]) 514 515 create_split_manifest( 516 targets=args, 517 manifest_file=manifest_file, 518 split_manifest_file=split_manifest_file, 519 config_files=config_files, 520 repo_list_file=repo_list_file, 521 ninja_build_file=ninja_build_file, 522 ninja_binary=ninja_binary, 523 module_info_file=module_info_file, 524 kati_stamp_file=kati_stamp_file, 525 overlays=overlays) 526 527 528if __name__ == "__main__": 529 main(sys.argv[1:]) 530