blob: 6ca3d886200ce5f9a1a3c29b6111473e6dd6f58e [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
Simon Glass252de6b2022-01-09 20:13:49 -070046class 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
Simon Glass00f674d2023-02-22 12:14:47 -070054 # Directory to store tools
Simon Glass932e40d2023-02-22 12:14:48 -070055 tooldir = os.path.join(os.getenv('HOME'), '.binman-tools')
Simon Glass00f674d2023-02-22 12:14:47 -070056
Quentin Schulze4408432022-09-01 17:51:40 +020057 def __init__(self, name, desc, version_regex=None, version_args='-V'):
Simon Glass252de6b2022-01-09 20:13:49 -070058 self.name = name
59 self.desc = desc
Quentin Schulz723a63e2022-09-01 17:51:37 +020060 self.version_regex = version_regex
Quentin Schulze4408432022-09-01 17:51:40 +020061 self.version_args = version_args
Simon Glass252de6b2022-01-09 20:13:49 -070062
63 @staticmethod
64 def find_bintool_class(btype):
65 """Look up the bintool class for bintool
66
67 Args:
68 byte: Bintool to use, e.g. 'mkimage'
69
70 Returns:
71 The bintool class object if found, else a tuple:
72 module name that could not be found
73 exception received
74 """
75 # Convert something like 'u-boot' to 'u_boot' since we are only
76 # interested in the type.
77 module_name = btype.replace('-', '_')
78 module = modules.get(module_name)
Stefan Herbrechtsmeier0f369d72022-08-19 16:25:35 +020079 class_name = f'Bintool{module_name}'
Simon Glass252de6b2022-01-09 20:13:49 -070080
81 # Import the module if we have not already done so
82 if not module:
83 try:
84 module = importlib.import_module('binman.btool.' + module_name)
85 except ImportError as exc:
Stefan Herbrechtsmeier0f369d72022-08-19 16:25:35 +020086 try:
87 # Deal with classes which must be renamed due to conflicts
88 # with Python libraries
Stefan Herbrechtsmeier0f369d72022-08-19 16:25:35 +020089 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] != '_']
Quentin Schulz478332a2022-11-07 13:54:54 +0100140 names = [name[6:] if name.startswith('btool_') else name
141 for name in names]
Simon Glass252de6b2022-01-09 20:13:49 -0700142 if include_testing:
143 names.append('_testing')
144 return sorted(names)
145
146 @staticmethod
147 def list_all():
148 """List all the bintools known to binman"""
149 names = Bintool.get_tool_list()
150 print(FORMAT % ('Name', 'Version', 'Description', 'Path'))
151 print(FORMAT % ('-' * 15,'-' * 11, '-' * 25, '-' * 30))
152 for name in names:
153 btool = Bintool.create(name)
154 btool.show()
155
156 def is_present(self):
157 """Check if a bintool is available on the system
158
159 Returns:
160 bool: True if available, False if not
161 """
162 if self.name in self.missing_list:
163 return False
164 return bool(self.get_path())
165
166 def get_path(self):
167 """Get the path of a bintool
168
169 Returns:
170 str: Path to the tool, if available, else None
171 """
172 return tools.tool_find(self.name)
173
174 def fetch_tool(self, method, col, skip_present):
175 """Fetch a single tool
176
177 Args:
178 method (FETCH_...): Method to use
179 col (terminal.Color): Color terminal object
180 skip_present (boo;): Skip fetching if it is already present
181
182 Returns:
183 int: Result of fetch either FETCHED, FAIL, PRESENT
184 """
185 def try_fetch(meth):
186 res = None
187 try:
188 res = self.fetch(meth)
189 except urllib.error.URLError as uerr:
190 message = uerr.reason
Simon Glass252ac582022-01-29 14:14:17 -0700191 print(col.build(col.RED, f'- {message}'))
Simon Glass252de6b2022-01-09 20:13:49 -0700192
193 except ValueError as exc:
194 print(f'Exception: {exc}')
195 return res
196
197 if skip_present and self.is_present():
198 return PRESENT
Simon Glass252ac582022-01-29 14:14:17 -0700199 print(col.build(col.YELLOW, 'Fetch: %s' % self.name))
Simon Glass252de6b2022-01-09 20:13:49 -0700200 if method == FETCH_ANY:
201 for try_method in range(1, FETCH_COUNT):
202 print(f'- trying method: {FETCH_NAMES[try_method]}')
203 result = try_fetch(try_method)
204 if result:
205 break
206 else:
207 result = try_fetch(method)
208 if not result:
209 return FAIL
210 if result is not True:
211 fname, tmpdir = result
Simon Glass00f674d2023-02-22 12:14:47 -0700212 dest = os.path.join(self.tooldir, self.name)
Simon Glass252de6b2022-01-09 20:13:49 -0700213 print(f"- writing to '{dest}'")
214 shutil.move(fname, dest)
215 if tmpdir:
216 shutil.rmtree(tmpdir)
217 return FETCHED
218
219 @staticmethod
220 def fetch_tools(method, names_to_fetch):
221 """Fetch bintools from a suitable place
222
223 This fetches or builds the requested bintools so that they can be used
224 by binman
225
226 Args:
227 names_to_fetch (list of str): names of bintools to fetch
228
229 Returns:
230 True on success, False on failure
231 """
232 def show_status(color, prompt, names):
Simon Glass252ac582022-01-29 14:14:17 -0700233 print(col.build(
Simon Glass252de6b2022-01-09 20:13:49 -0700234 color, f'{prompt}:%s{len(names):2}: %s' %
235 (' ' * (16 - len(prompt)), ' '.join(names))))
236
237 col = terminal.Color()
238 skip_present = False
239 name_list = names_to_fetch
240 if len(names_to_fetch) == 1 and names_to_fetch[0] in ['all', 'missing']:
241 name_list = Bintool.get_tool_list()
242 if names_to_fetch[0] == 'missing':
243 skip_present = True
Simon Glass252ac582022-01-29 14:14:17 -0700244 print(col.build(col.YELLOW,
Simon Glass252de6b2022-01-09 20:13:49 -0700245 'Fetching tools: %s' % ' '.join(name_list)))
246 status = collections.defaultdict(list)
247 for name in name_list:
248 btool = Bintool.create(name)
249 result = btool.fetch_tool(method, col, skip_present)
250 status[result].append(name)
251 if result == FAIL:
252 if method == FETCH_ANY:
253 print('- failed to fetch with all methods')
254 else:
255 print(f"- method '{FETCH_NAMES[method]}' is not supported")
256
257 if len(name_list) > 1:
258 if skip_present:
259 show_status(col.GREEN, 'Already present', status[PRESENT])
260 show_status(col.GREEN, 'Tools fetched', status[FETCHED])
261 if status[FAIL]:
262 show_status(col.RED, 'Failures', status[FAIL])
263 return not status[FAIL]
264
265 def run_cmd_result(self, *args, binary=False, raise_on_error=True):
266 """Run the bintool using command-line arguments
267
268 Args:
269 args (list of str): Arguments to provide, in addition to the bintool
270 name
271 binary (bool): True to return output as bytes instead of str
272 raise_on_error (bool): True to raise a ValueError exception if the
273 tool returns a non-zero return code
274
275 Returns:
276 CommandResult: Resulting output from the bintool, or None if the
277 tool is not present
278 """
279 if self.name in self.missing_list:
280 return None
281 name = os.path.expanduser(self.name) # Expand paths containing ~
282 all_args = (name,) + args
283 env = tools.get_env_with_path()
Simon Glassf3385a52022-01-29 14:14:15 -0700284 tout.detail(f"bintool: {' '.join(all_args)}")
Simon Glassd9800692022-01-29 14:14:05 -0700285 result = command.run_pipe(
Simon Glass252de6b2022-01-09 20:13:49 -0700286 [all_args], capture=True, capture_stderr=True, env=env,
287 raise_on_error=False, binary=binary)
288
289 if result.return_code:
290 # Return None if the tool was not found. In this case there is no
291 # output from the tool and it does not appear on the path. We still
292 # try to run it (as above) since RunPipe() allows faking the tool's
293 # output
294 if not any([result.stdout, result.stderr, tools.tool_find(name)]):
Simon Glassf3385a52022-01-29 14:14:15 -0700295 tout.info(f"bintool '{name}' not found")
Simon Glass252de6b2022-01-09 20:13:49 -0700296 return None
297 if raise_on_error:
Simon Glassf3385a52022-01-29 14:14:15 -0700298 tout.info(f"bintool '{name}' failed")
Simon Glass252de6b2022-01-09 20:13:49 -0700299 raise ValueError("Error %d running '%s': %s" %
300 (result.return_code, ' '.join(all_args),
301 result.stderr or result.stdout))
302 if result.stdout:
Simon Glassf3385a52022-01-29 14:14:15 -0700303 tout.debug(result.stdout)
Simon Glass252de6b2022-01-09 20:13:49 -0700304 if result.stderr:
Simon Glassf3385a52022-01-29 14:14:15 -0700305 tout.debug(result.stderr)
Simon Glass252de6b2022-01-09 20:13:49 -0700306 return result
307
308 def run_cmd(self, *args, binary=False):
309 """Run the bintool using command-line arguments
310
311 Args:
312 args (list of str): Arguments to provide, in addition to the bintool
313 name
314 binary (bool): True to return output as bytes instead of str
315
316 Returns:
317 str or bytes: Resulting stdout from the bintool
318 """
319 result = self.run_cmd_result(*args, binary=binary)
320 if result:
321 return result.stdout
322
323 @classmethod
Simon Glassd64af082022-09-17 09:01:19 -0600324 def build_from_git(cls, git_repo, make_target, bintool_path, flags=None):
Simon Glass252de6b2022-01-09 20:13:49 -0700325 """Build a bintool from a git repo
326
327 This clones the repo in a temporary directory, builds it with 'make',
328 then returns the filename of the resulting executable bintool
329
330 Args:
331 git_repo (str): URL of git repo
332 make_target (str): Target to pass to 'make' to build the tool
333 bintool_path (str): Relative path of the tool in the repo, after
334 build is complete
Simon Glassd64af082022-09-17 09:01:19 -0600335 flags (list of str): Flags or variables to pass to make, or None
Simon Glass252de6b2022-01-09 20:13:49 -0700336
337 Returns:
338 tuple:
339 str: Filename of fetched file to copy to a suitable directory
340 str: Name of temp directory to remove, or None
341 or None on error
342 """
343 tmpdir = tempfile.mkdtemp(prefix='binmanf.')
344 print(f"- clone git repo '{git_repo}' to '{tmpdir}'")
Simon Glassc1aa66e2022-01-29 14:14:04 -0700345 tools.run('git', 'clone', '--depth', '1', git_repo, tmpdir)
Simon Glass252de6b2022-01-09 20:13:49 -0700346 print(f"- build target '{make_target}'")
Simon Glassd64af082022-09-17 09:01:19 -0600347 cmd = ['make', '-C', tmpdir, '-j', f'{multiprocessing.cpu_count()}',
348 make_target]
349 if flags:
350 cmd += flags
351 tools.run(*cmd)
Simon Glass252de6b2022-01-09 20:13:49 -0700352 fname = os.path.join(tmpdir, bintool_path)
353 if not os.path.exists(fname):
354 print(f"- File '{fname}' was not produced")
355 return None
356 return fname, tmpdir
357
358 @classmethod
359 def fetch_from_url(cls, url):
360 """Fetch a bintool from a URL
361
362 Args:
363 url (str): URL to fetch from
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 """
Simon Glassc1aa66e2022-01-29 14:14:04 -0700370 fname, tmpdir = tools.download(url)
371 tools.run('chmod', 'a+x', fname)
Simon Glass252de6b2022-01-09 20:13:49 -0700372 return fname, tmpdir
373
374 @classmethod
375 def fetch_from_drive(cls, drive_id):
376 """Fetch a bintool from Google drive
377
378 Args:
379 drive_id (str): ID of file to fetch. For a URL of the form
380 'https://drive.google.com/file/d/xxx/view?usp=sharing' the value
381 passed here should be 'xxx'
382
383 Returns:
384 tuple:
385 str: Filename of fetched file to copy to a suitable directory
386 str: Name of temp directory to remove, or None
387 """
388 url = f'https://drive.google.com/uc?export=download&id={drive_id}'
389 return cls.fetch_from_url(url)
390
391 @classmethod
392 def apt_install(cls, package):
Simon Glass6569cb82023-02-22 12:14:45 -0700393 """Install a bintool using the 'apt' tool
Simon Glass252de6b2022-01-09 20:13:49 -0700394
395 This requires use of servo so may request a password
396
397 Args:
398 package (str): Name of package to install
399
400 Returns:
401 True, assuming it completes without error
402 """
403 args = ['sudo', 'apt', 'install', '-y', package]
404 print('- %s' % ' '.join(args))
Simon Glassc1aa66e2022-01-29 14:14:04 -0700405 tools.run(*args)
Simon Glass252de6b2022-01-09 20:13:49 -0700406 return True
407
Simon Glassbc570642022-01-09 20:14:11 -0700408 @staticmethod
409 def WriteDocs(modules, test_missing=None):
410 """Write out documentation about the various bintools to stdout
411
412 Args:
413 modules: List of modules to include
414 test_missing: Used for testing. This is a module to report
415 as missing
416 """
417 print('''.. SPDX-License-Identifier: GPL-2.0+
418
419Binman bintool Documentation
420============================
421
422This file describes the bintools (binary tools) supported by binman. Bintools
423are binman's name for external executables that it runs to generate or process
424binaries. It is fairly easy to create new bintools. Just add a new file to the
425'btool' directory. You can use existing bintools as examples.
426
427
428''')
429 modules = sorted(modules)
430 missing = []
431 for name in modules:
432 module = Bintool.find_bintool_class(name)
433 docs = getattr(module, '__doc__')
434 if test_missing == name:
435 docs = None
436 if docs:
437 lines = docs.splitlines()
438 first_line = lines[0]
439 rest = [line[4:] for line in lines[1:]]
440 hdr = 'Bintool: %s: %s' % (name, first_line)
441 print(hdr)
442 print('-' * len(hdr))
443 print('\n'.join(rest))
444 print()
445 print()
446 else:
447 missing.append(name)
448
449 if missing:
450 raise ValueError('Documentation is missing for modules: %s' %
451 ', '.join(missing))
452
Simon Glass252de6b2022-01-09 20:13:49 -0700453 # pylint: disable=W0613
454 def fetch(self, method):
455 """Fetch handler for a bintool
456
457 This should be implemented by the base class
458
459 Args:
460 method (FETCH_...): Method to use
461
462 Returns:
463 tuple:
464 str: Filename of fetched file to copy to a suitable directory
465 str: Name of temp directory to remove, or None
466 or True if the file was fetched and already installed
467 or None if no fetch() implementation is available
468
469 Raises:
470 Valuerror: Fetching could not be completed
471 """
472 print(f"No method to fetch bintool '{self.name}'")
473 return False
474
Simon Glass252de6b2022-01-09 20:13:49 -0700475 def version(self):
476 """Version handler for a bintool
477
Simon Glass252de6b2022-01-09 20:13:49 -0700478 Returns:
479 str: Version string for this bintool
480 """
Quentin Schulz723a63e2022-09-01 17:51:37 +0200481 if self.version_regex is None:
482 return 'unknown'
483
484 import re
485
Quentin Schulze4408432022-09-01 17:51:40 +0200486 result = self.run_cmd_result(self.version_args)
Quentin Schulz723a63e2022-09-01 17:51:37 +0200487 out = result.stdout.strip()
488 if not out:
489 out = result.stderr.strip()
490 if not out:
491 return 'unknown'
492
493 m_version = re.search(self.version_regex, out)
494 return m_version.group(1) if m_version else out
495
Stefan Herbrechtsmeier867eed12022-08-19 16:25:33 +0200496
497class BintoolPacker(Bintool):
498 """Tool which compression / decompression entry contents
499
500 This is a bintools base class for compression / decompression packer
501
502 Properties:
503 name: Name of packer tool
504 compression: Compression type (COMPRESS_...), value of 'name' property
505 if none
506 compress_args: List of positional args provided to tool for compress,
507 ['--compress'] if none
508 decompress_args: List of positional args provided to tool for
509 decompress, ['--decompress'] if none
510 fetch_package: Name of the tool installed using the apt, value of 'name'
511 property if none
512 version_regex: Regular expressions to extract the version from tool
513 version output, '(v[0-9.]+)' if none
514 """
515 def __init__(self, name, compression=None, compress_args=None,
516 decompress_args=None, fetch_package=None,
Quentin Schulze4408432022-09-01 17:51:40 +0200517 version_regex=r'(v[0-9.]+)', version_args='-V'):
Stefan Herbrechtsmeier867eed12022-08-19 16:25:33 +0200518 desc = '%s compression' % (compression if compression else name)
Quentin Schulze4408432022-09-01 17:51:40 +0200519 super().__init__(name, desc, version_regex, version_args)
Stefan Herbrechtsmeier867eed12022-08-19 16:25:33 +0200520 if compress_args is None:
521 compress_args = ['--compress']
522 self.compress_args = compress_args
523 if decompress_args is None:
524 decompress_args = ['--decompress']
525 self.decompress_args = decompress_args
526 if fetch_package is None:
527 fetch_package = name
528 self.fetch_package = fetch_package
Stefan Herbrechtsmeier867eed12022-08-19 16:25:33 +0200529
530 def compress(self, indata):
531 """Compress data
532
533 Args:
534 indata (bytes): Data to compress
535
536 Returns:
537 bytes: Compressed data
538 """
539 with tempfile.NamedTemporaryFile(prefix='comp.tmp',
540 dir=tools.get_output_dir()) as tmp:
541 tools.write_file(tmp.name, indata)
542 args = self.compress_args + ['--stdout', tmp.name]
543 return self.run_cmd(*args, binary=True)
544
545 def decompress(self, indata):
546 """Decompress data
547
548 Args:
549 indata (bytes): Data to decompress
550
551 Returns:
552 bytes: Decompressed data
553 """
554 with tempfile.NamedTemporaryFile(prefix='decomp.tmp',
555 dir=tools.get_output_dir()) as inf:
556 tools.write_file(inf.name, indata)
557 args = self.decompress_args + ['--stdout', inf.name]
558 return self.run_cmd(*args, binary=True)
559
560 def fetch(self, method):
561 """Fetch handler
562
563 This installs the gzip package using the apt utility.
564
565 Args:
566 method (FETCH_...): Method to use
567
568 Returns:
569 True if the file was fetched and now installed, None if a method
570 other than FETCH_BIN was requested
571
572 Raises:
573 Valuerror: Fetching could not be completed
574 """
575 if method != FETCH_BIN:
576 return None
577 return self.apt_install(self.fetch_package)