1#!/usr/bin/env python3
2
3# Copyright (C) 2023 The Android Open Source Project
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9#      http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16
17import argparse
18import asyncio
19import collections
20import json
21import os
22import socket
23import subprocess
24import sys
25import textwrap
26
27def get_top() -> str:
28  path = '.'
29  while not os.path.isfile(os.path.join(path, 'build/soong/tests/genrule_sandbox_test.py')):
30    if os.path.abspath(path) == '/':
31      sys.exit('Could not find android source tree root.')
32    path = os.path.join(path, '..')
33  return os.path.abspath(path)
34
35async def _build_with_soong(out_dir, targets, *, extra_env={}):
36  env = os.environ | extra_env
37
38  # Use nsjail to remap the out_dir to out/, because some genrules write the path to the out
39  # dir into their artifacts, so if the out directories were different it would cause a diff
40  # that doesn't really matter.
41  args = [
42      'prebuilts/build-tools/linux-x86/bin/nsjail',
43      '-q',
44      '--cwd',
45      os.getcwd(),
46      '-e',
47      '-B',
48      '/',
49      '-B',
50      f'{os.path.abspath(out_dir)}:{os.path.abspath("out")}',
51      '--time_limit',
52      '0',
53      '--skip_setsid',
54      '--keep_caps',
55      '--disable_clone_newcgroup',
56      '--disable_clone_newnet',
57      '--rlimit_as',
58      'soft',
59      '--rlimit_core',
60      'soft',
61      '--rlimit_cpu',
62      'soft',
63      '--rlimit_fsize',
64      'soft',
65      '--rlimit_nofile',
66      'soft',
67      '--proc_rw',
68      '--hostname',
69      socket.gethostname(),
70      '--',
71      "build/soong/soong_ui.bash",
72      "--make-mode",
73      "--skip-soong-tests",
74  ]
75  args.extend(targets)
76  process = await asyncio.create_subprocess_exec(
77      *args,
78      stdout=asyncio.subprocess.PIPE,
79      stderr=asyncio.subprocess.PIPE,
80      env=env,
81  )
82  stdout, stderr = await process.communicate()
83  if process.returncode != 0:
84    print(stdout)
85    print(stderr)
86    sys.exit(process.returncode)
87
88
89async def _find_outputs_for_modules(modules):
90  module_path = "out/soong/module-actions.json"
91
92  if not os.path.exists(module_path):
93    await _build_with_soong('out', ["json-module-graph"])
94
95  with open(module_path) as f:
96    action_graph = json.load(f)
97
98  module_to_outs = collections.defaultdict(set)
99  for mod in action_graph:
100    name = mod["Name"]
101    if name in modules:
102      for act in (mod["Module"]["Actions"] or []):
103        if "}generate" in act["Desc"]:
104          module_to_outs[name].update(act["Outputs"])
105  return module_to_outs
106
107
108def _compare_outputs(module_to_outs, tempdir) -> dict[str, list[str]]:
109  different_modules = collections.defaultdict(list)
110  for module, outs in module_to_outs.items():
111    for out in outs:
112      try:
113        subprocess.check_output(["diff", os.path.join(tempdir, out), out])
114      except subprocess.CalledProcessError as e:
115        different_modules[module].append(e.stdout)
116
117  return different_modules
118
119
120async def main():
121  parser = argparse.ArgumentParser()
122  parser.add_argument(
123      "modules",
124      nargs="+",
125      help="modules to compare builds with genrule sandboxing enabled/not",
126  )
127  parser.add_argument(
128      "--check-determinism",
129      action="store_true",
130      help="Don't check for working sandboxing. Instead, run two default builds, and compare their outputs. This is used to check for nondeterminsim, which would also affect the sandboxed test.",
131  )
132  parser.add_argument(
133      "--show-diff",
134      "-d",
135      action="store_true",
136      help="whether to display differing files",
137  )
138  parser.add_argument(
139      "--output-paths-only",
140      "-o",
141      action="store_true",
142      help="Whether to only return the output paths per module",
143  )
144  args = parser.parse_args()
145  os.chdir(get_top())
146
147  if "TARGET_PRODUCT" not in os.environ:
148    sys.exit("Please run lunch first")
149  if os.environ.get("OUT_DIR", "out") != "out":
150    sys.exit(f"This script expects OUT_DIR to be 'out', got: '{os.environ.get('OUT_DIR')}'")
151
152  print("finding output files for the modules...")
153  module_to_outs = await _find_outputs_for_modules(set(args.modules))
154  if not module_to_outs:
155    sys.exit("No outputs found")
156
157  if args.output_paths_only:
158    for m, o in module_to_outs.items():
159      print(f"{m} outputs: {o}")
160    sys.exit(0)
161
162  all_outs = list(set.union(*module_to_outs.values()))
163  for i, out in enumerate(all_outs):
164    if not out.startswith("out/"):
165      sys.exit("Expected output file to start with out/, found: " + out)
166
167  other_out_dir = "out_check_determinism" if args.check_determinism else "out_not_sandboxed"
168  other_env = {"GENRULE_SANDBOXING": "false"}
169  if args.check_determinism:
170    other_env = {}
171
172  # nsjail will complain if the out dir doesn't exist
173  os.makedirs("out", exist_ok=True)
174  os.makedirs(other_out_dir, exist_ok=True)
175
176  print("building...")
177  await asyncio.gather(
178    _build_with_soong("out", all_outs),
179    _build_with_soong(other_out_dir, all_outs, extra_env=other_env)
180  )
181
182  diffs = collections.defaultdict(dict)
183  for module, outs in module_to_outs.items():
184    for out in outs:
185      try:
186        subprocess.check_output(["diff", os.path.join(other_out_dir, out.removeprefix("out/")), out])
187      except subprocess.CalledProcessError as e:
188        diffs[module][out] = e.stdout
189
190  if len(diffs) == 0:
191    print("All modules are correct")
192  elif args.show_diff:
193    for m, files in diffs.items():
194      print(f"Module {m} has diffs:")
195      for f, d in files.items():
196        print("  "+f+":")
197        print(textwrap.indent(d, "    "))
198  else:
199    print(f"Modules {list(diffs.keys())} have diffs in these files:")
200    all_diff_files = [f for m in diffs.values() for f in m]
201    for f in all_diff_files:
202      print(f)
203
204
205
206if __name__ == "__main__":
207  asyncio.run(main())
208