blob: a582d9d3446964f44d89ea53ab690e5f9f4adf99 [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
Simon Glassd64af082022-09-17 09:01:19 -0600322 def build_from_git(cls, git_repo, make_target, bintool_path, flags=None):
Simon Glass252de6b2022-01-09 20:13:49 -0700323 """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
Simon Glassd64af082022-09-17 09:01:19 -0600333 flags (list of str): Flags or variables to pass to make, or None
Simon Glass252de6b2022-01-09 20:13:49 -0700334
335 Returns:
336 tuple:
337 str: Filename of fetched file to copy to a suitable directory
338 str: Name of temp directory to remove, or None
339 or None on error
340 """
341 tmpdir = tempfile.mkdtemp(prefix='binmanf.')
342 print(f"- clone git repo '{git_repo}' to '{tmpdir}'")
Simon Glassc1aa66e2022-01-29 14:14:04 -0700343 tools.run('git', 'clone', '--depth', '1', git_repo, tmpdir)
Simon Glass252de6b2022-01-09 20:13:49 -0700344 print(f"- build target '{make_target}'")
Simon Glassd64af082022-09-17 09:01:19 -0600345 cmd = ['make', '-C', tmpdir, '-j', f'{multiprocessing.cpu_count()}',
346 make_target]
347 if flags:
348 cmd += flags
349 tools.run(*cmd)
Simon Glass252de6b2022-01-09 20:13:49 -0700350 fname = os.path.join(tmpdir, bintool_path)
351 if not os.path.exists(fname):
352 print(f"- File '{fname}' was not produced")
353 return None
354 return fname, tmpdir
355
356 @classmethod
357 def fetch_from_url(cls, url):
358 """Fetch a bintool from a URL
359
360 Args:
361 url (str): URL to fetch from
362
363 Returns:
364 tuple:
365 str: Filename of fetched file to copy to a suitable directory
366 str: Name of temp directory to remove, or None
367 """
Simon Glassc1aa66e2022-01-29 14:14:04 -0700368 fname, tmpdir = tools.download(url)
369 tools.run('chmod', 'a+x', fname)
Simon Glass252de6b2022-01-09 20:13:49 -0700370 return fname, tmpdir
371
372 @classmethod
373 def fetch_from_drive(cls, drive_id):
374 """Fetch a bintool from Google drive
375
376 Args:
377 drive_id (str): ID of file to fetch. For a URL of the form
378 'https://drive.google.com/file/d/xxx/view?usp=sharing' the value
379 passed here should be 'xxx'
380
381 Returns:
382 tuple:
383 str: Filename of fetched file to copy to a suitable directory
384 str: Name of temp directory to remove, or None
385 """
386 url = f'https://drive.google.com/uc?export=download&id={drive_id}'
387 return cls.fetch_from_url(url)
388
389 @classmethod
390 def apt_install(cls, package):
391 """Install a bintool using the 'aot' tool
392
393 This requires use of servo so may request a password
394
395 Args:
396 package (str): Name of package to install
397
398 Returns:
399 True, assuming it completes without error
400 """
401 args = ['sudo', 'apt', 'install', '-y', package]
402 print('- %s' % ' '.join(args))
Simon Glassc1aa66e2022-01-29 14:14:04 -0700403 tools.run(*args)
Simon Glass252de6b2022-01-09 20:13:49 -0700404 return True
405
Simon Glassbc570642022-01-09 20:14:11 -0700406 @staticmethod
407 def WriteDocs(modules, test_missing=None):
408 """Write out documentation about the various bintools to stdout
409
410 Args:
411 modules: List of modules to include
412 test_missing: Used for testing. This is a module to report
413 as missing
414 """
415 print('''.. SPDX-License-Identifier: GPL-2.0+
416
417Binman bintool Documentation
418============================
419
420This file describes the bintools (binary tools) supported by binman. Bintools
421are binman's name for external executables that it runs to generate or process
422binaries. It is fairly easy to create new bintools. Just add a new file to the
423'btool' directory. You can use existing bintools as examples.
424
425
426''')
427 modules = sorted(modules)
428 missing = []
429 for name in modules:
430 module = Bintool.find_bintool_class(name)
431 docs = getattr(module, '__doc__')
432 if test_missing == name:
433 docs = None
434 if docs:
435 lines = docs.splitlines()
436 first_line = lines[0]
437 rest = [line[4:] for line in lines[1:]]
438 hdr = 'Bintool: %s: %s' % (name, first_line)
439 print(hdr)
440 print('-' * len(hdr))
441 print('\n'.join(rest))
442 print()
443 print()
444 else:
445 missing.append(name)
446
447 if missing:
448 raise ValueError('Documentation is missing for modules: %s' %
449 ', '.join(missing))
450
Simon Glass252de6b2022-01-09 20:13:49 -0700451 # pylint: disable=W0613
452 def fetch(self, method):
453 """Fetch handler for a bintool
454
455 This should be implemented by the base class
456
457 Args:
458 method (FETCH_...): Method to use
459
460 Returns:
461 tuple:
462 str: Filename of fetched file to copy to a suitable directory
463 str: Name of temp directory to remove, or None
464 or True if the file was fetched and already installed
465 or None if no fetch() implementation is available
466
467 Raises:
468 Valuerror: Fetching could not be completed
469 """
470 print(f"No method to fetch bintool '{self.name}'")
471 return False
472
Simon Glass252de6b2022-01-09 20:13:49 -0700473 def version(self):
474 """Version handler for a bintool
475
Simon Glass252de6b2022-01-09 20:13:49 -0700476 Returns:
477 str: Version string for this bintool
478 """
Quentin Schulz723a63e2022-09-01 17:51:37 +0200479 if self.version_regex is None:
480 return 'unknown'
481
482 import re
483
Quentin Schulze4408432022-09-01 17:51:40 +0200484 result = self.run_cmd_result(self.version_args)
Quentin Schulz723a63e2022-09-01 17:51:37 +0200485 out = result.stdout.strip()
486 if not out:
487 out = result.stderr.strip()
488 if not out:
489 return 'unknown'
490
491 m_version = re.search(self.version_regex, out)
492 return m_version.group(1) if m_version else out
493
Stefan Herbrechtsmeier867eed12022-08-19 16:25:33 +0200494
495class BintoolPacker(Bintool):
496 """Tool which compression / decompression entry contents
497
498 This is a bintools base class for compression / decompression packer
499
500 Properties:
501 name: Name of packer tool
502 compression: Compression type (COMPRESS_...), value of 'name' property
503 if none
504 compress_args: List of positional args provided to tool for compress,
505 ['--compress'] if none
506 decompress_args: List of positional args provided to tool for
507 decompress, ['--decompress'] if none
508 fetch_package: Name of the tool installed using the apt, value of 'name'
509 property if none
510 version_regex: Regular expressions to extract the version from tool
511 version output, '(v[0-9.]+)' if none
512 """
513 def __init__(self, name, compression=None, compress_args=None,
514 decompress_args=None, fetch_package=None,
Quentin Schulze4408432022-09-01 17:51:40 +0200515 version_regex=r'(v[0-9.]+)', version_args='-V'):
Stefan Herbrechtsmeier867eed12022-08-19 16:25:33 +0200516 desc = '%s compression' % (compression if compression else name)
Quentin Schulze4408432022-09-01 17:51:40 +0200517 super().__init__(name, desc, version_regex, version_args)
Stefan Herbrechtsmeier867eed12022-08-19 16:25:33 +0200518 if compress_args is None:
519 compress_args = ['--compress']
520 self.compress_args = compress_args
521 if decompress_args is None:
522 decompress_args = ['--decompress']
523 self.decompress_args = decompress_args
524 if fetch_package is None:
525 fetch_package = name
526 self.fetch_package = fetch_package
Stefan Herbrechtsmeier867eed12022-08-19 16:25:33 +0200527
528 def compress(self, indata):
529 """Compress data
530
531 Args:
532 indata (bytes): Data to compress
533
534 Returns:
535 bytes: Compressed data
536 """
537 with tempfile.NamedTemporaryFile(prefix='comp.tmp',
538 dir=tools.get_output_dir()) as tmp:
539 tools.write_file(tmp.name, indata)
540 args = self.compress_args + ['--stdout', tmp.name]
541 return self.run_cmd(*args, binary=True)
542
543 def decompress(self, indata):
544 """Decompress data
545
546 Args:
547 indata (bytes): Data to decompress
548
549 Returns:
550 bytes: Decompressed data
551 """
552 with tempfile.NamedTemporaryFile(prefix='decomp.tmp',
553 dir=tools.get_output_dir()) as inf:
554 tools.write_file(inf.name, indata)
555 args = self.decompress_args + ['--stdout', inf.name]
556 return self.run_cmd(*args, binary=True)
557
558 def fetch(self, method):
559 """Fetch handler
560
561 This installs the gzip package using the apt utility.
562
563 Args:
564 method (FETCH_...): Method to use
565
566 Returns:
567 True if the file was fetched and now installed, None if a method
568 other than FETCH_BIN was requested
569
570 Raises:
571 Valuerror: Fetching could not be completed
572 """
573 if method != FETCH_BIN:
574 return None
575 return self.apt_install(self.fetch_package)