1#!/usr/bin/python3 2# 3# Copyright (C) 2022 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 json 18import os 19import sys 20 21EXIT_STATUS_OK = 0 22EXIT_STATUS_ERROR = 1 23EXIT_STATUS_NEED_HELP = 2 24_VALID_VARIANTS = ["user", "userdebug", "eng"] 25 26 27def find_dirs(path, name, ttl=6): 28 """Search at most ttl directories deep inside path for a directory called name 29 and yield directories that match.""" 30 # The dance with subdirs is so that we recurse in sorted order. 31 subdirs = [] 32 with os.scandir(path) as it: 33 for dirent in sorted(it, key=lambda x: x.name): 34 try: 35 if dirent.is_dir(): 36 if dirent.name == name: 37 yield os.path.join(path, dirent.name) 38 elif ttl > 0: 39 subdirs.append(dirent.name) 40 except OSError: 41 # Consume filesystem errors, e.g. too many links, permission etc. 42 pass 43 for subdir in subdirs: 44 yield from find_dirs(os.path.join(path, subdir), name, ttl - 1) 45 46 47def walk_paths(path, matcher, ttl=10): 48 """Do a traversal of all files under path yielding each file that matches 49 matcher.""" 50 # First look for files, then recurse into directories as needed. 51 # The dance with subdirs is so that we recurse in sorted order. 52 subdirs = [] 53 with os.scandir(path) as it: 54 for dirent in sorted(it, key=lambda x: x.name): 55 try: 56 if dirent.is_file(): 57 if matcher(dirent.name): 58 yield os.path.join(path, dirent.name) 59 if dirent.is_dir(): 60 if ttl > 0: 61 subdirs.append(dirent.name) 62 except OSError: 63 # Consume filesystem errors, e.g. too many links, permission etc. 64 pass 65 for subdir in sorted(subdirs): 66 yield from walk_paths(os.path.join(path, subdir), matcher, ttl - 1) 67 68 69def find_file(path, filename): 70 """Return a file called filename inside path, no more than ttl levels deep. 71 72 Directories are searched alphabetically. 73 """ 74 for f in walk_paths(path, lambda x: x == filename): 75 return f 76 77 78class LunchContext(object): 79 """Mockable container for lunch""" 80 81 def __init__(self, workspace_root, orchestrator_path_prefix_components=()): 82 prefix = orchestrator_path_prefix_components or ("orchestrator", 83 "build") 84 self.workspace_root = workspace_root 85 self.orchestrator_path_prefix_components = prefix 86 87 88def find_config_dirs(context): 89 """Find the configuration files in the well known locations inside workspace_root 90 91 <workspace_root>/<orchestrator>/<path>/<prefix>/orchestrator/multitree_combos 92 (AOSP devices, such as cuttlefish) 93 94 <workspace_root>/vendor/**/multitree_combos 95 (specific to a vendor and not open sourced) 96 97 <workspace_root>/device/**/multitree_combos 98 (specific to a vendor and are open sourced) 99 100 Directories are returned specifically in this order, so that aosp can't be 101 overridden, but vendor overrides device. 102 """ 103 # TODO: This is not looking in inner trees correctly. 104 105 yield os.path.join(context.workspace_root, 106 *context.orchestrator_path_prefix_components, 107 "orchestrator/multitree_combos") 108 109 dirs = ["vendor", "device"] 110 for d in dirs: 111 yield from find_dirs(os.path.join(context.workspace_root, d), 112 "multitree_combos") 113 114 115def find_named_config(context, shortname): 116 """Find the config with the given shortname inside context.workspace_root. 117 118 Config directories are searched in the order described in find_config_dirs, 119 and inside those directories, alphabetically.""" 120 filename = shortname + ".mcombo" 121 for config_dir in find_config_dirs(context): 122 found = find_file(config_dir, filename) 123 if found: 124 return found 125 return None 126 127 128def parse_product_variant(s): 129 """Split a PRODUCT-VARIANT name, or return None if it doesn't match that pattern.""" 130 split = s.split("-") 131 if len(split) != 2: 132 return None 133 return split 134 135 136def choose_config_from_args(context, args): 137 """Return the config file we should use for the given argument, 138 or null if there's no file that matches that.""" 139 if len(args) == 1: 140 # Prefer PRODUCT-VARIANT syntax so if there happens to be a matching 141 # file we don't match that. 142 pv = parse_product_variant(args[0]) 143 if pv: 144 config = find_named_config(context, pv[0]) 145 if config: 146 return (config, pv[1]) 147 return None, None 148 # Look for a specifically named file 149 if os.path.isfile(args[0]): 150 return (args[0], args[1] if len(args) > 1 else None) 151 # That file didn't exist, return that we didn't find it. 152 return None, None 153 154 155class ConfigException(Exception): 156 ERROR_IDENTIFY = "identify" 157 ERROR_PARSE = "parse" 158 ERROR_CYCLE = "cycle" 159 ERROR_VALIDATE = "validate" 160 161 def __init__(self, kind, message, locations=(), line=0): 162 """Error thrown when loading and parsing configurations. 163 164 Args: 165 message: Error message to display to user 166 locations: List of filenames of the include history. The 0 index one 167 the location where the actual error occurred 168 """ 169 if locations: 170 s = locations[0] 171 if line: 172 s += ":" 173 s += str(line) 174 s += ": " 175 else: 176 s = "" 177 s += message 178 if locations: 179 for loc in locations[1:]: 180 s += f"\n included from {loc}" 181 super().__init__(s) 182 self.kind = kind 183 self.message = message 184 self.locations = locations 185 self.line = line 186 187 188def load_config(filename): 189 """Load a config, including processing the inherits fields. 190 191 Raises: 192 ConfigException on errors 193 """ 194 195 def load_and_merge(fn, visited): 196 with open(fn, encoding='iso-8859-1') as f: 197 try: 198 contents = json.load(f) 199 except json.decoder.JSONDecodeError as ex: 200 # Pretty up the exception message. 201 # pylint: disable=raise-missing-from 202 raise ConfigException(ConfigException.ERROR_PARSE, ex.msg, 203 visited, ex.lineno) 204 # sys.stderr.write("exception %s" % ex.__dict__) 205 # raise ex 206 # Merge all the parents into one data, with first-wins policy 207 inherited_data = {} 208 for parent in contents.get("inherits", []): 209 if parent in visited: 210 raise ConfigException(ConfigException.ERROR_CYCLE, 211 "Cycle detected in inherits", 212 visited) 213 deep_merge(inherited_data, 214 load_and_merge(parent, [parent] + visited)) 215 # Then merge inherited_data into contents, but what's already there will win. 216 deep_merge(contents, inherited_data) 217 contents.pop("inherits", None) 218 return contents 219 220 return load_and_merge(filename, [filename]) 221 222 223def deep_merge(merged, addition): 224 """Merge all fields of addition into merged. Pre-existing fields win.""" 225 for k, v in addition.items(): 226 if k in merged: 227 if isinstance(v, dict) and isinstance(merged[k], dict): 228 deep_merge(merged[k], v) 229 else: 230 merged[k] = v 231 232 233def make_config_header(config_file, config, variant): 234 def make_table(rows): 235 maxcols = max([len(row) for row in rows]) 236 widths = [0] * maxcols 237 for row in rows: 238 for i, v in enumerate(row): 239 widths[i] = max(widths[i], len(v)) 240 text = [] 241 for row in rows: 242 rowtext = [] 243 for i, cell in enumerate(row): 244 rowtext.append(str(cell)) 245 rowtext.append(" " * (widths[i] - len(cell))) 246 rowtext.append(" ") 247 text.append("".join(rowtext)) 248 return "\n".join(text) 249 250 trees = [("Component", "Path", "Product"), 251 ("---------", "----", "-------")] 252 253 def add_config_tuple(trees, entry, name): 254 if entry: 255 trees.append( 256 (name, entry.get("inner-tree"), entry.get("product", ""))) 257 258 add_config_tuple(trees, config.get("system"), "system") 259 add_config_tuple(trees, config.get("vendor"), "vendor") 260 for k, v in config.get("modules", {}).items(): 261 add_config_tuple(trees, v, k) 262 263 return "\n".join([ 264 "========================================", 265 f"TARGET_BUILD_COMBO={config_file}", 266 f"TARGET_BUILD_VARIANT={variant}", 267 "", 268 f"{make_table(trees)}", 269 "========================================", 270 "", 271 ]) 272 273 274def do_lunch(args): 275 """Handle the lunch command.""" 276 # Check that we're at the top of a multitree workspace by seeing if this 277 # script exists. 278 if not os.path.exists("orchestrator/build/orchestrator/core/lunch.py"): 279 sys.stderr.write( 280 "ERROR: lunch.py must be run in the root of the multi-tree" 281 " workspace\n") 282 return EXIT_STATUS_ERROR 283 284 # Choose the config file 285 config_file, variant = choose_config_from_args(".", args) 286 287 if config_file is None: 288 sys.stderr.write( 289 f"Can't find lunch combo file for: {' '.join(args)}\n") 290 return EXIT_STATUS_NEED_HELP 291 if variant is None: 292 sys.stderr.write(f"Can't find variant for: {' '.join(args)}\n") 293 return EXIT_STATUS_NEED_HELP 294 295 # Parse the config file 296 try: 297 config = load_config(config_file) 298 except ConfigException as ex: 299 sys.stderr.write(str(ex)) 300 return EXIT_STATUS_ERROR 301 302 # Fail if the lunchable bit isn't set, because this isn't a usable config. 303 if not config.get("lunchable", False): 304 sys.stderr.write( 305 "{config_file}: Lunch config file (or inherited files) does" 306 " not have the 'lunchable' flag set, which means it is" 307 " probably not a complete lunch spec.\n") 308 309 # All the validation has passed: print the name of the file and the variant. 310 sys.stdout.write(f"{config_file}\n{variant}\n") 311 312 # Write confirmation message to stderr 313 sys.stderr.write(make_config_header(config_file, config, variant)) 314 315 return EXIT_STATUS_OK 316 317 318def find_all_combo_files(context): 319 """Find all .mcombo files in the prescribed locations in the tree.""" 320 for directory in find_config_dirs(context): 321 for file in walk_paths(directory, lambda x: x.endswith(".mcombo")): 322 yield file 323 324 325def is_file_lunchable(config_file): 326 """Parse config_file, flatten the inheritance, and return whether it can be 327 used as a lunch target.""" 328 try: 329 config = load_config(config_file) 330 except ConfigException as ex: 331 sys.stderr.write(str(ex)) 332 return False 333 return config.get("lunchable", False) 334 335 336def find_all_lunchable(context): 337 """Find all lunchable mcombo files in the tree. 338 339 The search is rooted at context.workspace_root. Lunchable mcombo files have 340 `lunchable: true` when parsed (and inheritance is flattened). 341 """ 342 for combo in find_all_combo_files(context): 343 if is_file_lunchable(combo): 344 yield combo 345 346 347def load_current_config(): 348 """Load, validate and return the config as specified in TARGET_BUILD_COMBO. Throws 349 ConfigException if there is a problem.""" 350 351 # Identify the config file 352 config_file = os.environ.get("TARGET_BUILD_COMBO") 353 if not config_file: 354 raise ConfigException( 355 ConfigException.ERROR_IDENTIFY, 356 "TARGET_BUILD_COMBO not set. Run lunch or pass a combo file.") 357 358 # Parse the config file 359 config = load_config(config_file) 360 361 # Validate the config file 362 if not config.get("lunchable", False): 363 raise ConfigException( 364 ConfigException.ERROR_VALIDATE, 365 ("Lunch config file (or an inherited file) does not have the" 366 " 'lunchable' flag set, which means it is probably not a complete" 367 " lunch spec."), [config_file]) 368 369 # TODO: Validate that: 370 # - there are no modules called system or vendor 371 # - everything has all the required files 372 373 variant = os.environ.get("TARGET_BUILD_VARIANT") 374 if not variant: 375 variant = "eng" # TODO: Is this the right default? 376 # Validate variant is user, userdebug or eng 377 if variant not in _VALID_VARIANTS: 378 raise ConfigException( 379 ConfigException.ERROR_VALIDATE, 380 f"Variant must be one of: {', '.join(_VALID_VARIANTS)}.", 381 [config_file]) 382 383 return config_file, config, variant 384 385 386def do_list(): 387 """Handle the --list command.""" 388 lunch_context = LunchContext(".") 389 for f in sorted(find_all_lunchable(lunch_context)): 390 print(f) 391 392 393def do_print(args): 394 """Handle the --print command.""" 395 # Parse args 396 if len(args) == 0: 397 config_file = os.environ.get("TARGET_BUILD_COMBO") 398 if not config_file: 399 sys.stderr.write( 400 "TARGET_BUILD_COMBO not set. Run lunch before building.\n") 401 return EXIT_STATUS_NEED_HELP 402 elif len(args) == 1: 403 config_file = args[0] 404 else: 405 return EXIT_STATUS_NEED_HELP 406 407 # Parse the config file 408 try: 409 config = load_config(config_file) 410 except ConfigException as ex: 411 sys.stderr.write(str(ex)) 412 return EXIT_STATUS_ERROR 413 414 # Print the config in json form 415 json.dump(config, sys.stdout, indent=4) 416 417 return EXIT_STATUS_OK 418 419 420def main(argv): 421 if len(argv) < 2 or argv[1] == "-h" or argv[1] == "--help": 422 return EXIT_STATUS_NEED_HELP 423 424 if len(argv) == 2 and argv[1] == "--list": 425 do_list() 426 return EXIT_STATUS_OK 427 428 if len(argv) == 2 and argv[1] == "--print": 429 do_print(argv[2:]) 430 return EXIT_STATUS_OK 431 432 if (len(argv) == 3 or len(argv) == 4) and argv[1] == "--lunch": 433 return do_lunch(argv[2:]) 434 435 sys.stderr.write(f"Unknown lunch command: {' '.join(argv[1:])}\n") 436 return EXIT_STATUS_NEED_HELP 437 438 439if __name__ == "__main__": 440 sys.exit(main(sys.argv)) 441 442# vim: sts=4:ts=4:sw=4 443