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