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