blob: ef2bdeb69681141ba7c3e2c88179eba72344b2c8 [file] [log] [blame]
Simon Glass861fbbf2022-01-09 20:13:49 -07001# SPDX-License-Identifier: GPL-2.0+
2# Copyright 2022 Google LLC
Stefan Herbrechtsmeier23cb8212022-08-19 16:25:33 +02003# Copyright (C) 2022 Weidmüller Interface GmbH & Co. KG
4# Stefan Herbrechtsmeier <stefan.herbrechtsmeier@weidmueller.com>
Simon Glass861fbbf2022-01-09 20:13:49 -07005#
6"""Base class for all bintools
7
8This defines the common functionality for all bintools, including running
9the tool, checking its version and fetching it if needed.
10"""
11
12import collections
13import glob
14import importlib
15import multiprocessing
16import os
17import shutil
18import tempfile
19import urllib.error
20
21from patman import command
22from patman import terminal
23from patman import tools
24from patman import tout
25
26BINMAN_DIR = os.path.dirname(os.path.realpath(__file__))
27
28# Format string for listing bintools, see also the header in list_all()
29FORMAT = '%-16.16s %-12.12s %-26.26s %s'
30
31# List of known modules, to avoid importing the module multiple times
32modules = {}
33
34# Possible ways of fetching a tool (FETCH_COUNT is number of ways)
35FETCH_ANY, FETCH_BIN, FETCH_BUILD, FETCH_COUNT = range(4)
36
37FETCH_NAMES = {
38 FETCH_ANY: 'any method',
39 FETCH_BIN: 'binary download',
40 FETCH_BUILD: 'build from source'
41 }
42
43# Status of tool fetching
44FETCHED, FAIL, PRESENT, STATUS_COUNT = range(4)
45
46DOWNLOAD_DESTDIR = os.path.join(os.getenv('HOME'), 'bin')
47
48class Bintool:
49 """Tool which operates on binaries to help produce entry contents
50
51 This is the base class for all bintools
52 """
53 # List of bintools to regard as missing
54 missing_list = []
55
Quentin Schulzbbfc1372022-09-01 17:51:37 +020056 def __init__(self, name, desc, version_regex=None):
Simon Glass861fbbf2022-01-09 20:13:49 -070057 self.name = name
58 self.desc = desc
Quentin Schulzbbfc1372022-09-01 17:51:37 +020059 self.version_regex = version_regex
Simon Glass861fbbf2022-01-09 20:13:49 -070060
61 @staticmethod
62 def find_bintool_class(btype):
63 """Look up the bintool class for bintool
64
65 Args:
66 byte: Bintool to use, e.g. 'mkimage'
67
68 Returns:
69 The bintool class object if found, else a tuple:
70 module name that could not be found
71 exception received
72 """
73 # Convert something like 'u-boot' to 'u_boot' since we are only
74 # interested in the type.
75 module_name = btype.replace('-', '_')
76 module = modules.get(module_name)
Stefan Herbrechtsmeier2b8cda62022-08-19 16:25:35 +020077 class_name = f'Bintool{module_name}'
Simon Glass861fbbf2022-01-09 20:13:49 -070078
79 # Import the module if we have not already done so
80 if not module:
81 try:
82 module = importlib.import_module('binman.btool.' + module_name)
83 except ImportError as exc:
Stefan Herbrechtsmeier2b8cda62022-08-19 16:25:35 +020084 try:
85 # Deal with classes which must be renamed due to conflicts
86 # with Python libraries
87 class_name = f'Bintoolbtool_{module_name}'
88 module = importlib.import_module('binman.btool.btool_' +
89 module_name)
90 except ImportError:
91 return module_name, exc
Simon Glass861fbbf2022-01-09 20:13:49 -070092 modules[module_name] = module
93
94 # Look up the expected class name
Stefan Herbrechtsmeier2b8cda62022-08-19 16:25:35 +020095 return getattr(module, class_name)
Simon Glass861fbbf2022-01-09 20:13:49 -070096
97 @staticmethod
98 def create(name):
99 """Create a new bintool object
100
101 Args:
102 name (str): Bintool to create, e.g. 'mkimage'
103
104 Returns:
105 A new object of the correct type (a subclass of Binutil)
106 """
107 cls = Bintool.find_bintool_class(name)
108 if isinstance(cls, tuple):
109 raise ValueError("Cannot import bintool module '%s': %s" % cls)
110
111 # Call its constructor to get the object we want.
112 obj = cls(name)
113 return obj
114
115 def show(self):
116 """Show a line of information about a bintool"""
117 if self.is_present():
118 version = self.version()
119 else:
120 version = '-'
121 print(FORMAT % (self.name, version, self.desc,
122 self.get_path() or '(not found)'))
123
124 @classmethod
125 def set_missing_list(cls, missing_list):
126 cls.missing_list = missing_list or []
127
128 @staticmethod
129 def get_tool_list(include_testing=False):
130 """Get a list of the known tools
131
132 Returns:
133 list of str: names of all tools known to binman
134 """
135 files = glob.glob(os.path.join(BINMAN_DIR, 'btool/*'))
136 names = [os.path.splitext(os.path.basename(fname))[0]
137 for fname in files]
138 names = [name for name in names if name[0] != '_']
139 if include_testing:
140 names.append('_testing')
141 return sorted(names)
142
143 @staticmethod
144 def list_all():
145 """List all the bintools known to binman"""
146 names = Bintool.get_tool_list()
147 print(FORMAT % ('Name', 'Version', 'Description', 'Path'))
148 print(FORMAT % ('-' * 15,'-' * 11, '-' * 25, '-' * 30))
149 for name in names:
150 btool = Bintool.create(name)
151 btool.show()
152
153 def is_present(self):
154 """Check if a bintool is available on the system
155
156 Returns:
157 bool: True if available, False if not
158 """
159 if self.name in self.missing_list:
160 return False
161 return bool(self.get_path())
162
163 def get_path(self):
164 """Get the path of a bintool
165
166 Returns:
167 str: Path to the tool, if available, else None
168 """
169 return tools.tool_find(self.name)
170
171 def fetch_tool(self, method, col, skip_present):
172 """Fetch a single tool
173
174 Args:
175 method (FETCH_...): Method to use
176 col (terminal.Color): Color terminal object
177 skip_present (boo;): Skip fetching if it is already present
178
179 Returns:
180 int: Result of fetch either FETCHED, FAIL, PRESENT
181 """
182 def try_fetch(meth):
183 res = None
184 try:
185 res = self.fetch(meth)
186 except urllib.error.URLError as uerr:
187 message = uerr.reason
Simon Glassf45d3742022-01-29 14:14:17 -0700188 print(col.build(col.RED, f'- {message}'))
Simon Glass861fbbf2022-01-09 20:13:49 -0700189
190 except ValueError as exc:
191 print(f'Exception: {exc}')
192 return res
193
194 if skip_present and self.is_present():
195 return PRESENT
Simon Glassf45d3742022-01-29 14:14:17 -0700196 print(col.build(col.YELLOW, 'Fetch: %s' % self.name))
Simon Glass861fbbf2022-01-09 20:13:49 -0700197 if method == FETCH_ANY:
198 for try_method in range(1, FETCH_COUNT):
199 print(f'- trying method: {FETCH_NAMES[try_method]}')
200 result = try_fetch(try_method)
201 if result:
202 break
203 else:
204 result = try_fetch(method)
205 if not result:
206 return FAIL
207 if result is not True:
208 fname, tmpdir = result
209 dest = os.path.join(DOWNLOAD_DESTDIR, self.name)
210 print(f"- writing to '{dest}'")
211 shutil.move(fname, dest)
212 if tmpdir:
213 shutil.rmtree(tmpdir)
214 return FETCHED
215
216 @staticmethod
217 def fetch_tools(method, names_to_fetch):
218 """Fetch bintools from a suitable place
219
220 This fetches or builds the requested bintools so that they can be used
221 by binman
222
223 Args:
224 names_to_fetch (list of str): names of bintools to fetch
225
226 Returns:
227 True on success, False on failure
228 """
229 def show_status(color, prompt, names):
Simon Glassf45d3742022-01-29 14:14:17 -0700230 print(col.build(
Simon Glass861fbbf2022-01-09 20:13:49 -0700231 color, f'{prompt}:%s{len(names):2}: %s' %
232 (' ' * (16 - len(prompt)), ' '.join(names))))
233
234 col = terminal.Color()
235 skip_present = False
236 name_list = names_to_fetch
237 if len(names_to_fetch) == 1 and names_to_fetch[0] in ['all', 'missing']:
238 name_list = Bintool.get_tool_list()
239 if names_to_fetch[0] == 'missing':
240 skip_present = True
Simon Glassf45d3742022-01-29 14:14:17 -0700241 print(col.build(col.YELLOW,
Simon Glass861fbbf2022-01-09 20:13:49 -0700242 'Fetching tools: %s' % ' '.join(name_list)))
243 status = collections.defaultdict(list)
244 for name in name_list:
245 btool = Bintool.create(name)
246 result = btool.fetch_tool(method, col, skip_present)
247 status[result].append(name)
248 if result == FAIL:
249 if method == FETCH_ANY:
250 print('- failed to fetch with all methods')
251 else:
252 print(f"- method '{FETCH_NAMES[method]}' is not supported")
253
254 if len(name_list) > 1:
255 if skip_present:
256 show_status(col.GREEN, 'Already present', status[PRESENT])
257 show_status(col.GREEN, 'Tools fetched', status[FETCHED])
258 if status[FAIL]:
259 show_status(col.RED, 'Failures', status[FAIL])
260 return not status[FAIL]
261
262 def run_cmd_result(self, *args, binary=False, raise_on_error=True):
263 """Run the bintool using command-line arguments
264
265 Args:
266 args (list of str): Arguments to provide, in addition to the bintool
267 name
268 binary (bool): True to return output as bytes instead of str
269 raise_on_error (bool): True to raise a ValueError exception if the
270 tool returns a non-zero return code
271
272 Returns:
273 CommandResult: Resulting output from the bintool, or None if the
274 tool is not present
275 """
276 if self.name in self.missing_list:
277 return None
278 name = os.path.expanduser(self.name) # Expand paths containing ~
279 all_args = (name,) + args
280 env = tools.get_env_with_path()
Simon Glass011f1b32022-01-29 14:14:15 -0700281 tout.detail(f"bintool: {' '.join(all_args)}")
Simon Glass840be732022-01-29 14:14:05 -0700282 result = command.run_pipe(
Simon Glass861fbbf2022-01-09 20:13:49 -0700283 [all_args], capture=True, capture_stderr=True, env=env,
284 raise_on_error=False, binary=binary)
285
286 if result.return_code:
287 # Return None if the tool was not found. In this case there is no
288 # output from the tool and it does not appear on the path. We still
289 # try to run it (as above) since RunPipe() allows faking the tool's
290 # output
291 if not any([result.stdout, result.stderr, tools.tool_find(name)]):
Simon Glass011f1b32022-01-29 14:14:15 -0700292 tout.info(f"bintool '{name}' not found")
Simon Glass861fbbf2022-01-09 20:13:49 -0700293 return None
294 if raise_on_error:
Simon Glass011f1b32022-01-29 14:14:15 -0700295 tout.info(f"bintool '{name}' failed")
Simon Glass861fbbf2022-01-09 20:13:49 -0700296 raise ValueError("Error %d running '%s': %s" %
297 (result.return_code, ' '.join(all_args),
298 result.stderr or result.stdout))
299 if result.stdout:
Simon Glass011f1b32022-01-29 14:14:15 -0700300 tout.debug(result.stdout)
Simon Glass861fbbf2022-01-09 20:13:49 -0700301 if result.stderr:
Simon Glass011f1b32022-01-29 14:14:15 -0700302 tout.debug(result.stderr)
Simon Glass861fbbf2022-01-09 20:13:49 -0700303 return result
304
305 def run_cmd(self, *args, binary=False):
306 """Run the bintool using command-line arguments
307
308 Args:
309 args (list of str): Arguments to provide, in addition to the bintool
310 name
311 binary (bool): True to return output as bytes instead of str
312
313 Returns:
314 str or bytes: Resulting stdout from the bintool
315 """
316 result = self.run_cmd_result(*args, binary=binary)
317 if result:
318 return result.stdout
319
320 @classmethod
321 def build_from_git(cls, git_repo, make_target, bintool_path):
322 """Build a bintool from a git repo
323
324 This clones the repo in a temporary directory, builds it with 'make',
325 then returns the filename of the resulting executable bintool
326
327 Args:
328 git_repo (str): URL of git repo
329 make_target (str): Target to pass to 'make' to build the tool
330 bintool_path (str): Relative path of the tool in the repo, after
331 build is complete
332
333 Returns:
334 tuple:
335 str: Filename of fetched file to copy to a suitable directory
336 str: Name of temp directory to remove, or None
337 or None on error
338 """
339 tmpdir = tempfile.mkdtemp(prefix='binmanf.')
340 print(f"- clone git repo '{git_repo}' to '{tmpdir}'")
Simon Glass80025522022-01-29 14:14:04 -0700341 tools.run('git', 'clone', '--depth', '1', git_repo, tmpdir)
Simon Glass861fbbf2022-01-09 20:13:49 -0700342 print(f"- build target '{make_target}'")
Simon Glass80025522022-01-29 14:14:04 -0700343 tools.run('make', '-C', tmpdir, '-j', f'{multiprocessing.cpu_count()}',
Simon Glass861fbbf2022-01-09 20:13:49 -0700344 make_target)
345 fname = os.path.join(tmpdir, bintool_path)
346 if not os.path.exists(fname):
347 print(f"- File '{fname}' was not produced")
348 return None
349 return fname, tmpdir
350
351 @classmethod
352 def fetch_from_url(cls, url):
353 """Fetch a bintool from a URL
354
355 Args:
356 url (str): URL to fetch from
357
358 Returns:
359 tuple:
360 str: Filename of fetched file to copy to a suitable directory
361 str: Name of temp directory to remove, or None
362 """
Simon Glass80025522022-01-29 14:14:04 -0700363 fname, tmpdir = tools.download(url)
364 tools.run('chmod', 'a+x', fname)
Simon Glass861fbbf2022-01-09 20:13:49 -0700365 return fname, tmpdir
366
367 @classmethod
368 def fetch_from_drive(cls, drive_id):
369 """Fetch a bintool from Google drive
370
371 Args:
372 drive_id (str): ID of file to fetch. For a URL of the form
373 'https://drive.google.com/file/d/xxx/view?usp=sharing' the value
374 passed here should be 'xxx'
375
376 Returns:
377 tuple:
378 str: Filename of fetched file to copy to a suitable directory
379 str: Name of temp directory to remove, or None
380 """
381 url = f'https://drive.google.com/uc?export=download&id={drive_id}'
382 return cls.fetch_from_url(url)
383
384 @classmethod
385 def apt_install(cls, package):
386 """Install a bintool using the 'aot' tool
387
388 This requires use of servo so may request a password
389
390 Args:
391 package (str): Name of package to install
392
393 Returns:
394 True, assuming it completes without error
395 """
396 args = ['sudo', 'apt', 'install', '-y', package]
397 print('- %s' % ' '.join(args))
Simon Glass80025522022-01-29 14:14:04 -0700398 tools.run(*args)
Simon Glass861fbbf2022-01-09 20:13:49 -0700399 return True
400
Simon Glass620c4462022-01-09 20:14:11 -0700401 @staticmethod
402 def WriteDocs(modules, test_missing=None):
403 """Write out documentation about the various bintools to stdout
404
405 Args:
406 modules: List of modules to include
407 test_missing: Used for testing. This is a module to report
408 as missing
409 """
410 print('''.. SPDX-License-Identifier: GPL-2.0+
411
412Binman bintool Documentation
413============================
414
415This file describes the bintools (binary tools) supported by binman. Bintools
416are binman's name for external executables that it runs to generate or process
417binaries. It is fairly easy to create new bintools. Just add a new file to the
418'btool' directory. You can use existing bintools as examples.
419
420
421''')
422 modules = sorted(modules)
423 missing = []
424 for name in modules:
425 module = Bintool.find_bintool_class(name)
426 docs = getattr(module, '__doc__')
427 if test_missing == name:
428 docs = None
429 if docs:
430 lines = docs.splitlines()
431 first_line = lines[0]
432 rest = [line[4:] for line in lines[1:]]
433 hdr = 'Bintool: %s: %s' % (name, first_line)
434 print(hdr)
435 print('-' * len(hdr))
436 print('\n'.join(rest))
437 print()
438 print()
439 else:
440 missing.append(name)
441
442 if missing:
443 raise ValueError('Documentation is missing for modules: %s' %
444 ', '.join(missing))
445
Simon Glass861fbbf2022-01-09 20:13:49 -0700446 # pylint: disable=W0613
447 def fetch(self, method):
448 """Fetch handler for a bintool
449
450 This should be implemented by the base class
451
452 Args:
453 method (FETCH_...): Method to use
454
455 Returns:
456 tuple:
457 str: Filename of fetched file to copy to a suitable directory
458 str: Name of temp directory to remove, or None
459 or True if the file was fetched and already installed
460 or None if no fetch() implementation is available
461
462 Raises:
463 Valuerror: Fetching could not be completed
464 """
465 print(f"No method to fetch bintool '{self.name}'")
466 return False
467
Simon Glass861fbbf2022-01-09 20:13:49 -0700468 def version(self):
469 """Version handler for a bintool
470
Simon Glass861fbbf2022-01-09 20:13:49 -0700471 Returns:
472 str: Version string for this bintool
473 """
Quentin Schulzbbfc1372022-09-01 17:51:37 +0200474 if self.version_regex is None:
475 return 'unknown'
476
477 import re
478
479 result = self.run_cmd_result('-V')
480 out = result.stdout.strip()
481 if not out:
482 out = result.stderr.strip()
483 if not out:
484 return 'unknown'
485
486 m_version = re.search(self.version_regex, out)
487 return m_version.group(1) if m_version else out
488
Stefan Herbrechtsmeier23cb8212022-08-19 16:25:33 +0200489
490class BintoolPacker(Bintool):
491 """Tool which compression / decompression entry contents
492
493 This is a bintools base class for compression / decompression packer
494
495 Properties:
496 name: Name of packer tool
497 compression: Compression type (COMPRESS_...), value of 'name' property
498 if none
499 compress_args: List of positional args provided to tool for compress,
500 ['--compress'] if none
501 decompress_args: List of positional args provided to tool for
502 decompress, ['--decompress'] if none
503 fetch_package: Name of the tool installed using the apt, value of 'name'
504 property if none
505 version_regex: Regular expressions to extract the version from tool
506 version output, '(v[0-9.]+)' if none
507 """
508 def __init__(self, name, compression=None, compress_args=None,
509 decompress_args=None, fetch_package=None,
510 version_regex=r'(v[0-9.]+)'):
511 desc = '%s compression' % (compression if compression else name)
Quentin Schulzbbfc1372022-09-01 17:51:37 +0200512 super().__init__(name, desc, version_regex)
Stefan Herbrechtsmeier23cb8212022-08-19 16:25:33 +0200513 if compress_args is None:
514 compress_args = ['--compress']
515 self.compress_args = compress_args
516 if decompress_args is None:
517 decompress_args = ['--decompress']
518 self.decompress_args = decompress_args
519 if fetch_package is None:
520 fetch_package = name
521 self.fetch_package = fetch_package
Stefan Herbrechtsmeier23cb8212022-08-19 16:25:33 +0200522
523 def compress(self, indata):
524 """Compress data
525
526 Args:
527 indata (bytes): Data to compress
528
529 Returns:
530 bytes: Compressed data
531 """
532 with tempfile.NamedTemporaryFile(prefix='comp.tmp',
533 dir=tools.get_output_dir()) as tmp:
534 tools.write_file(tmp.name, indata)
535 args = self.compress_args + ['--stdout', tmp.name]
536 return self.run_cmd(*args, binary=True)
537
538 def decompress(self, indata):
539 """Decompress data
540
541 Args:
542 indata (bytes): Data to decompress
543
544 Returns:
545 bytes: Decompressed data
546 """
547 with tempfile.NamedTemporaryFile(prefix='decomp.tmp',
548 dir=tools.get_output_dir()) as inf:
549 tools.write_file(inf.name, indata)
550 args = self.decompress_args + ['--stdout', inf.name]
551 return self.run_cmd(*args, binary=True)
552
553 def fetch(self, method):
554 """Fetch handler
555
556 This installs the gzip package using the apt utility.
557
558 Args:
559 method (FETCH_...): Method to use
560
561 Returns:
562 True if the file was fetched and now installed, None if a method
563 other than FETCH_BIN was requested
564
565 Raises:
566 Valuerror: Fetching could not be completed
567 """
568 if method != FETCH_BIN:
569 return None
570 return self.apt_install(self.fetch_package)