bintool.py 20 KB

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