• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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