xref: /aosp_15_r20/build/soong/scripts/ninja_determinism_test.py (revision 333d2b3687b3a337dbcca9d65000bca186795e39)
1#!/usr/bin/env python3
2
3import asyncio
4import argparse
5import dataclasses
6import hashlib
7import os
8import re
9import socket
10import subprocess
11import sys
12import zipfile
13
14from typing import List
15
16def get_top() -> str:
17  path = '.'
18  while not os.path.isfile(os.path.join(path, 'build/soong/soong_ui.bash')):
19    if os.path.abspath(path) == '/':
20      sys.exit('Could not find android source tree root.')
21    path = os.path.join(path, '..')
22  return os.path.abspath(path)
23
24
25_PRODUCT_REGEX = re.compile(r'([a-zA-Z_][a-zA-Z0-9_]*)(?:(?:-([a-zA-Z_][a-zA-Z0-9_]*))?-(user|userdebug|eng))?')
26
27
28@dataclasses.dataclass(frozen=True)
29class Product:
30  """Represents a TARGET_PRODUCT and TARGET_BUILD_VARIANT."""
31  product: str
32  release: str
33  variant: str
34
35  def __post_init__(self):
36    if not _PRODUCT_REGEX.match(str(self)):
37      raise ValueError(f'Invalid product name: {self}')
38
39  def __str__(self):
40    return self.product + '-' + self.release + '-' + self.variant
41
42
43async def run_make_nothing(product: Product, out_dir: str) -> bool:
44  """Runs a build and returns if it succeeded or not."""
45  with open(os.path.join(out_dir, 'build.log'), 'wb') as f:
46    result = await asyncio.create_subprocess_exec(
47        'prebuilts/build-tools/linux-x86/bin/nsjail',
48        '-q',
49        '--cwd',
50        os.getcwd(),
51        '-e',
52        '-B',
53        '/',
54        '-B',
55        f'{os.path.abspath(out_dir)}:{os.path.join(os.getcwd(), "out")}',
56        '--time_limit',
57        '0',
58        '--skip_setsid',
59        '--keep_caps',
60        '--disable_clone_newcgroup',
61        '--disable_clone_newnet',
62        '--rlimit_as',
63        'soft',
64        '--rlimit_core',
65        'soft',
66        '--rlimit_cpu',
67        'soft',
68        '--rlimit_fsize',
69        'soft',
70        '--rlimit_nofile',
71        'soft',
72        '--proc_rw',
73        '--hostname',
74        socket.gethostname(),
75        '--',
76        'build/soong/soong_ui.bash',
77        '--make-mode',
78        f'TARGET_PRODUCT={product.product}',
79        f'TARGET_RELEASE={product.release}',
80        f'TARGET_BUILD_VARIANT={product.variant}',
81        '--skip-ninja',
82        'nothing', stdout=f, stderr=subprocess.STDOUT)
83    return await result.wait() == 0
84
85SUBNINJA_OR_INCLUDE_REGEX = re.compile(rb'\n(?:include|subninja) ')
86
87def find_subninjas_and_includes(contents) -> List[str]:
88  results = []
89  def get_path_from_directive(i):
90    j = contents.find(b'\n', i)
91    if j < 0:
92      path_bytes = contents[i:]
93    else:
94      path_bytes = contents[i:j]
95    path_bytes = path_bytes.removesuffix(b'\r')
96    path = path_bytes.decode()
97    if '$' in path:
98      sys.exit('includes/subninjas with variables are unsupported: '+path)
99    return path
100
101  if contents.startswith(b"include "):
102    results.append(get_path_from_directive(len(b"include ")))
103  elif contents.startswith(b"subninja "):
104    results.append(get_path_from_directive(len(b"subninja ")))
105
106  for match in SUBNINJA_OR_INCLUDE_REGEX.finditer(contents):
107    results.append(get_path_from_directive(match.end()))
108
109  return results
110
111
112def transitively_included_ninja_files(out_dir: str, ninja_file: str, seen):
113  with open(ninja_file, 'rb') as f:
114    contents = f.read()
115
116  results = [ninja_file]
117  seen[ninja_file] = True
118  sub_files = find_subninjas_and_includes(contents)
119  for sub_file in sub_files:
120    sub_file = os.path.join(out_dir, sub_file.removeprefix('out/'))
121    if sub_file not in seen:
122      results.extend(transitively_included_ninja_files(out_dir, sub_file, seen))
123
124  return results
125
126
127def hash_ninja_file(out_dir: str, ninja_file: str, hasher):
128  with open(ninja_file, 'rb') as f:
129    contents = f.read()
130
131  sub_files = find_subninjas_and_includes(contents)
132
133  hasher.update(contents)
134
135  for sub_file in sub_files:
136    hash_ninja_file(out_dir, os.path.join(out_dir, sub_file.removeprefix('out/')), hasher)
137
138
139def hash_files(files: List[str]) -> str:
140  hasher = hashlib.md5()
141  for file in files:
142    with open(file, 'rb') as f:
143      hasher.update(f.read())
144  return hasher.hexdigest()
145
146
147def dist_ninja_files(out_dir: str, zip_name: str, ninja_files: List[str]):
148  dist_dir = os.getenv('DIST_DIR', os.path.join(os.getenv('OUT_DIR', 'out'), 'dist'))
149  os.makedirs(dist_dir, exist_ok=True)
150
151  with open(os.path.join(dist_dir, zip_name), 'wb') as f:
152    with zipfile.ZipFile(f, mode='w') as zf:
153      for ninja_file in ninja_files:
154        zf.write(ninja_file, arcname=os.path.basename(out_dir)+'/out/' + os.path.relpath(ninja_file, out_dir))
155
156
157async def main():
158    parser = argparse.ArgumentParser()
159    args = parser.parse_args()
160
161    os.chdir(get_top())
162    subprocess.check_call(['touch', 'build/soong/Android.bp'])
163
164    product = Product(
165      'aosp_cf_x86_64_phone',
166      'trunk_staging',
167      'userdebug',
168    )
169    os.environ['TARGET_PRODUCT'] = 'aosp_cf_x86_64_phone'
170    os.environ['TARGET_RELEASE'] = 'trunk_staging'
171    os.environ['TARGET_BUILD_VARIANT'] = 'userdebug'
172
173    out_dir1 = os.path.join(os.getenv('OUT_DIR', 'out'), 'determinism_test_out1')
174    out_dir2 = os.path.join(os.getenv('OUT_DIR', 'out'), 'determinism_test_out2')
175
176    os.makedirs(out_dir1, exist_ok=True)
177    os.makedirs(out_dir2, exist_ok=True)
178
179    success1, success2 = await asyncio.gather(
180      run_make_nothing(product, out_dir1),
181      run_make_nothing(product, out_dir2))
182
183    if not success1:
184      with open(os.path.join(out_dir1, 'build.log'), 'r') as f:
185        print(f.read(), file=sys.stderr)
186      sys.exit('build failed')
187    if not success2:
188      with open(os.path.join(out_dir2, 'build.log'), 'r') as f:
189        print(f.read(), file=sys.stderr)
190      sys.exit('build failed')
191
192    ninja_files1 = transitively_included_ninja_files(out_dir1, os.path.join(out_dir1, f'combined-{product.product}.ninja'), {})
193    ninja_files2 = transitively_included_ninja_files(out_dir2, os.path.join(out_dir2, f'combined-{product.product}.ninja'), {})
194
195    dist_ninja_files(out_dir1, 'determinism_test_files_1.zip', ninja_files1)
196    dist_ninja_files(out_dir2, 'determinism_test_files_2.zip', ninja_files2)
197
198    hash1 = hash_files(ninja_files1)
199    hash2 = hash_files(ninja_files2)
200
201    if hash1 != hash2:
202        sys.exit("ninja files were not deterministic! See disted determinism_test_files_1/2.zip")
203
204    print("Success, ninja files were deterministic")
205
206
207if __name__ == "__main__":
208    asyncio.run(main())
209
210
211