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