068d766c5074eeab3fa62604352f8e996c59cdb1
[platform/kernel/u-boot.git] / tools / binman / bintool.py
1 # SPDX-License-Identifier: GPL-2.0+
2 # Copyright 2022 Google LLC
3 #
4 """Base class for all bintools
5
6 This defines the common functionality for all bintools, including running
7 the tool, checking its version and fetching it if needed.
8 """
9
10 import collections
11 import glob
12 import importlib
13 import multiprocessing
14 import os
15 import shutil
16 import tempfile
17 import urllib.error
18
19 from patman import command
20 from patman import terminal
21 from patman import tools
22 from patman import tout
23
24 BINMAN_DIR = os.path.dirname(os.path.realpath(__file__))
25
26 # Format string for listing bintools, see also the header in list_all()
27 FORMAT = '%-16.16s %-12.12s %-26.26s %s'
28
29 # List of known modules, to avoid importing the module multiple times
30 modules = {}
31
32 # Possible ways of fetching a tool (FETCH_COUNT is number of ways)
33 FETCH_ANY, FETCH_BIN, FETCH_BUILD, FETCH_COUNT = range(4)
34
35 FETCH_NAMES = {
36     FETCH_ANY: 'any method',
37     FETCH_BIN: 'binary download',
38     FETCH_BUILD: 'build from source'
39     }
40
41 # Status of tool fetching
42 FETCHED, FAIL, PRESENT, STATUS_COUNT = range(4)
43
44 DOWNLOAD_DESTDIR = os.path.join(os.getenv('HOME'), 'bin')
45
46 class Bintool:
47     """Tool which operates on binaries to help produce entry contents
48
49     This is the base class for all bintools
50     """
51     # List of bintools to regard as missing
52     missing_list = []
53
54     def __init__(self, name, desc):
55         self.name = name
56         self.desc = desc
57
58     @staticmethod
59     def find_bintool_class(btype):
60         """Look up the bintool class for bintool
61
62         Args:
63             byte: Bintool to use, e.g. 'mkimage'
64
65         Returns:
66             The bintool class object if found, else a tuple:
67                 module name that could not be found
68                 exception received
69         """
70         # Convert something like 'u-boot' to 'u_boot' since we are only
71         # interested in the type.
72         module_name = btype.replace('-', '_')
73         module = modules.get(module_name)
74
75         # Import the module if we have not already done so
76         if not module:
77             try:
78                 module = importlib.import_module('binman.btool.' + module_name)
79             except ImportError as exc:
80                 return module_name, exc
81             modules[module_name] = module
82
83         # Look up the expected class name
84         return getattr(module, 'Bintool%s' % module_name)
85
86     @staticmethod
87     def create(name):
88         """Create a new bintool object
89
90         Args:
91             name (str): Bintool to create, e.g. 'mkimage'
92
93         Returns:
94             A new object of the correct type (a subclass of Binutil)
95         """
96         cls = Bintool.find_bintool_class(name)
97         if isinstance(cls, tuple):
98             raise ValueError("Cannot import bintool module '%s': %s" % cls)
99
100         # Call its constructor to get the object we want.
101         obj = cls(name)
102         return obj
103
104     def show(self):
105         """Show a line of information about a bintool"""
106         if self.is_present():
107             version = self.version()
108         else:
109             version = '-'
110         print(FORMAT % (self.name, version, self.desc,
111                         self.get_path() or '(not found)'))
112
113     @classmethod
114     def set_missing_list(cls, missing_list):
115         cls.missing_list = missing_list or []
116
117     @staticmethod
118     def get_tool_list(include_testing=False):
119         """Get a list of the known tools
120
121         Returns:
122             list of str: names of all tools known to binman
123         """
124         files = glob.glob(os.path.join(BINMAN_DIR, 'btool/*'))
125         names = [os.path.splitext(os.path.basename(fname))[0]
126                  for fname in files]
127         names = [name for name in names if name[0] != '_']
128         if include_testing:
129             names.append('_testing')
130         return sorted(names)
131
132     @staticmethod
133     def list_all():
134         """List all the bintools known to binman"""
135         names = Bintool.get_tool_list()
136         print(FORMAT % ('Name', 'Version', 'Description', 'Path'))
137         print(FORMAT % ('-' * 15,'-' * 11, '-' * 25, '-' * 30))
138         for name in names:
139             btool = Bintool.create(name)
140             btool.show()
141
142     def is_present(self):
143         """Check if a bintool is available on the system
144
145         Returns:
146             bool: True if available, False if not
147         """
148         if self.name in self.missing_list:
149             return False
150         return bool(self.get_path())
151
152     def get_path(self):
153         """Get the path of a bintool
154
155         Returns:
156             str: Path to the tool, if available, else None
157         """
158         return tools.tool_find(self.name)
159
160     def fetch_tool(self, method, col, skip_present):
161         """Fetch a single tool
162
163         Args:
164             method (FETCH_...): Method to use
165             col (terminal.Color): Color terminal object
166             skip_present (boo;): Skip fetching if it is already present
167
168         Returns:
169             int: Result of fetch either FETCHED, FAIL, PRESENT
170         """
171         def try_fetch(meth):
172             res = None
173             try:
174                 res = self.fetch(meth)
175             except urllib.error.URLError as uerr:
176                 message = uerr.reason
177                 print(col.Color(col.RED, f'- {message}'))
178
179             except ValueError as exc:
180                 print(f'Exception: {exc}')
181             return res
182
183         if skip_present and self.is_present():
184             return PRESENT
185         print(col.Color(col.YELLOW, 'Fetch: %s' % self.name))
186         if method == FETCH_ANY:
187             for try_method in range(1, FETCH_COUNT):
188                 print(f'- trying method: {FETCH_NAMES[try_method]}')
189                 result = try_fetch(try_method)
190                 if result:
191                     break
192         else:
193             result = try_fetch(method)
194         if not result:
195             return FAIL
196         if result is not True:
197             fname, tmpdir = result
198             dest = os.path.join(DOWNLOAD_DESTDIR, self.name)
199             print(f"- writing to '{dest}'")
200             shutil.move(fname, dest)
201             if tmpdir:
202                 shutil.rmtree(tmpdir)
203         return FETCHED
204
205     @staticmethod
206     def fetch_tools(method, names_to_fetch):
207         """Fetch bintools from a suitable place
208
209         This fetches or builds the requested bintools so that they can be used
210         by binman
211
212         Args:
213             names_to_fetch (list of str): names of bintools to fetch
214
215         Returns:
216             True on success, False on failure
217         """
218         def show_status(color, prompt, names):
219             print(col.Color(
220                 color, f'{prompt}:%s{len(names):2}: %s' %
221                 (' ' * (16 - len(prompt)), ' '.join(names))))
222
223         col = terminal.Color()
224         skip_present = False
225         name_list = names_to_fetch
226         if len(names_to_fetch) == 1 and names_to_fetch[0] in ['all', 'missing']:
227             name_list = Bintool.get_tool_list()
228             if names_to_fetch[0] == 'missing':
229                 skip_present = True
230             print(col.Color(col.YELLOW,
231                             'Fetching tools:      %s' % ' '.join(name_list)))
232         status = collections.defaultdict(list)
233         for name in name_list:
234             btool = Bintool.create(name)
235             result = btool.fetch_tool(method, col, skip_present)
236             status[result].append(name)
237             if result == FAIL:
238                 if method == FETCH_ANY:
239                     print('- failed to fetch with all methods')
240                 else:
241                     print(f"- method '{FETCH_NAMES[method]}' is not supported")
242
243         if len(name_list) > 1:
244             if skip_present:
245                 show_status(col.GREEN, 'Already present', status[PRESENT])
246             show_status(col.GREEN, 'Tools fetched', status[FETCHED])
247             if status[FAIL]:
248                 show_status(col.RED, 'Failures', status[FAIL])
249         return not status[FAIL]
250
251     def run_cmd_result(self, *args, binary=False, raise_on_error=True):
252         """Run the bintool using command-line arguments
253
254         Args:
255             args (list of str): Arguments to provide, in addition to the bintool
256                 name
257             binary (bool): True to return output as bytes instead of str
258             raise_on_error (bool): True to raise a ValueError exception if the
259                 tool returns a non-zero return code
260
261         Returns:
262             CommandResult: Resulting output from the bintool, or None if the
263                 tool is not present
264         """
265         if self.name in self.missing_list:
266             return None
267         name = os.path.expanduser(self.name)  # Expand paths containing ~
268         all_args = (name,) + args
269         env = tools.get_env_with_path()
270         tout.Detail(f"bintool: {' '.join(all_args)}")
271         result = command.RunPipe(
272             [all_args], capture=True, capture_stderr=True, env=env,
273             raise_on_error=False, binary=binary)
274
275         if result.return_code:
276             # Return None if the tool was not found. In this case there is no
277             # output from the tool and it does not appear on the path. We still
278             # try to run it (as above) since RunPipe() allows faking the tool's
279             # output
280             if not any([result.stdout, result.stderr, tools.tool_find(name)]):
281                 tout.Info(f"bintool '{name}' not found")
282                 return None
283             if raise_on_error:
284                 tout.Info(f"bintool '{name}' failed")
285                 raise ValueError("Error %d running '%s': %s" %
286                                 (result.return_code, ' '.join(all_args),
287                                 result.stderr or result.stdout))
288         if result.stdout:
289             tout.Debug(result.stdout)
290         if result.stderr:
291             tout.Debug(result.stderr)
292         return result
293
294     def run_cmd(self, *args, binary=False):
295         """Run the bintool using command-line arguments
296
297         Args:
298             args (list of str): Arguments to provide, in addition to the bintool
299                 name
300             binary (bool): True to return output as bytes instead of str
301
302         Returns:
303             str or bytes: Resulting stdout from the bintool
304         """
305         result = self.run_cmd_result(*args, binary=binary)
306         if result:
307             return result.stdout
308
309     @classmethod
310     def build_from_git(cls, git_repo, make_target, bintool_path):
311         """Build a bintool from a git repo
312
313         This clones the repo in a temporary directory, builds it with 'make',
314         then returns the filename of the resulting executable bintool
315
316         Args:
317             git_repo (str): URL of git repo
318             make_target (str): Target to pass to 'make' to build the tool
319             bintool_path (str): Relative path of the tool in the repo, after
320                 build is complete
321
322         Returns:
323             tuple:
324                 str: Filename of fetched file to copy to a suitable directory
325                 str: Name of temp directory to remove, or None
326             or None on error
327         """
328         tmpdir = tempfile.mkdtemp(prefix='binmanf.')
329         print(f"- clone git repo '{git_repo}' to '{tmpdir}'")
330         tools.run('git', 'clone', '--depth', '1', git_repo, tmpdir)
331         print(f"- build target '{make_target}'")
332         tools.run('make', '-C', tmpdir, '-j', f'{multiprocessing.cpu_count()}',
333                   make_target)
334         fname = os.path.join(tmpdir, bintool_path)
335         if not os.path.exists(fname):
336             print(f"- File '{fname}' was not produced")
337             return None
338         return fname, tmpdir
339
340     @classmethod
341     def fetch_from_url(cls, url):
342         """Fetch a bintool from a URL
343
344         Args:
345             url (str): URL to fetch from
346
347         Returns:
348             tuple:
349                 str: Filename of fetched file to copy to a suitable directory
350                 str: Name of temp directory to remove, or None
351         """
352         fname, tmpdir = tools.download(url)
353         tools.run('chmod', 'a+x', fname)
354         return fname, tmpdir
355
356     @classmethod
357     def fetch_from_drive(cls, drive_id):
358         """Fetch a bintool from Google drive
359
360         Args:
361             drive_id (str): ID of file to fetch. For a URL of the form
362             'https://drive.google.com/file/d/xxx/view?usp=sharing' the value
363             passed here should be 'xxx'
364
365         Returns:
366             tuple:
367                 str: Filename of fetched file to copy to a suitable directory
368                 str: Name of temp directory to remove, or None
369         """
370         url = f'https://drive.google.com/uc?export=download&id={drive_id}'
371         return cls.fetch_from_url(url)
372
373     @classmethod
374     def apt_install(cls, package):
375         """Install a bintool using the 'aot' tool
376
377         This requires use of servo so may request a password
378
379         Args:
380             package (str): Name of package to install
381
382         Returns:
383             True, assuming it completes without error
384         """
385         args = ['sudo', 'apt', 'install', '-y', package]
386         print('- %s' % ' '.join(args))
387         tools.run(*args)
388         return True
389
390     @staticmethod
391     def WriteDocs(modules, test_missing=None):
392         """Write out documentation about the various bintools to stdout
393
394         Args:
395             modules: List of modules to include
396             test_missing: Used for testing. This is a module to report
397                 as missing
398         """
399         print('''.. SPDX-License-Identifier: GPL-2.0+
400
401 Binman bintool Documentation
402 ============================
403
404 This file describes the bintools (binary tools) supported by binman. Bintools
405 are binman's name for external executables that it runs to generate or process
406 binaries. It is fairly easy to create new bintools. Just add a new file to the
407 'btool' directory. You can use existing bintools as examples.
408
409
410 ''')
411         modules = sorted(modules)
412         missing = []
413         for name in modules:
414             module = Bintool.find_bintool_class(name)
415             docs = getattr(module, '__doc__')
416             if test_missing == name:
417                 docs = None
418             if docs:
419                 lines = docs.splitlines()
420                 first_line = lines[0]
421                 rest = [line[4:] for line in lines[1:]]
422                 hdr = 'Bintool: %s: %s' % (name, first_line)
423                 print(hdr)
424                 print('-' * len(hdr))
425                 print('\n'.join(rest))
426                 print()
427                 print()
428             else:
429                 missing.append(name)
430
431         if missing:
432             raise ValueError('Documentation is missing for modules: %s' %
433                              ', '.join(missing))
434
435     # pylint: disable=W0613
436     def fetch(self, method):
437         """Fetch handler for a bintool
438
439         This should be implemented by the base class
440
441         Args:
442             method (FETCH_...): Method to use
443
444         Returns:
445             tuple:
446                 str: Filename of fetched file to copy to a suitable directory
447                 str: Name of temp directory to remove, or None
448             or True if the file was fetched and already installed
449             or None if no fetch() implementation is available
450
451         Raises:
452             Valuerror: Fetching could not be completed
453         """
454         print(f"No method to fetch bintool '{self.name}'")
455         return False
456
457     # pylint: disable=R0201
458     def version(self):
459         """Version handler for a bintool
460
461         This should be implemented by the base class
462
463         Returns:
464             str: Version string for this bintool
465         """
466         return 'unknown'