buildman: Convert config_out to string IO
[platform/kernel/u-boot.git] / tools / buildman / builderthread.py
1 # SPDX-License-Identifier: GPL-2.0+
2 # Copyright (c) 2014 Google, Inc
3 #
4
5 """Implementation the bulider threads
6
7 This module provides the BuilderThread class, which handles calling the builder
8 based on the jobs provided.
9 """
10
11 import errno
12 import glob
13 import io
14 import os
15 import shutil
16 import sys
17 import threading
18
19 from buildman import cfgutil
20 from patman import gitutil
21 from u_boot_pylib import command
22
23 RETURN_CODE_RETRY = -1
24 BASE_ELF_FILENAMES = ['u-boot', 'spl/u-boot-spl', 'tpl/u-boot-tpl']
25
26 def mkdir(dirname, parents = False):
27     """Make a directory if it doesn't already exist.
28
29     Args:
30         dirname: Directory to create
31     """
32     try:
33         if parents:
34             os.makedirs(dirname)
35         else:
36             os.mkdir(dirname)
37     except OSError as err:
38         if err.errno == errno.EEXIST:
39             if os.path.realpath('.') == os.path.realpath(dirname):
40                 print(f"Cannot create the current working directory '{dirname}'!")
41                 sys.exit(1)
42         else:
43             raise
44
45 # pylint: disable=R0903
46 class BuilderJob:
47     """Holds information about a job to be performed by a thread
48
49     Members:
50         brd: Board object to build
51         commits: List of Commit objects to build
52         keep_outputs: True to save build output files
53         step: 1 to process every commit, n to process every nth commit
54         work_in_output: Use the output directory as the work directory and
55             don't write to a separate output directory.
56     """
57     def __init__(self):
58         self.brd = None
59         self.commits = []
60         self.keep_outputs = False
61         self.step = 1
62         self.work_in_output = False
63
64
65 class ResultThread(threading.Thread):
66     """This thread processes results from builder threads.
67
68     It simply passes the results on to the builder. There is only one
69     result thread, and this helps to serialise the build output.
70     """
71     def __init__(self, builder):
72         """Set up a new result thread
73
74         Args:
75             builder: Builder which will be sent each result
76         """
77         threading.Thread.__init__(self)
78         self.builder = builder
79
80     def run(self):
81         """Called to start up the result thread.
82
83         We collect the next result job and pass it on to the build.
84         """
85         while True:
86             result = self.builder.out_queue.get()
87             self.builder.process_result(result)
88             self.builder.out_queue.task_done()
89
90
91 class BuilderThread(threading.Thread):
92     """This thread builds U-Boot for a particular board.
93
94     An input queue provides each new job. We run 'make' to build U-Boot
95     and then pass the results on to the output queue.
96
97     Members:
98         builder: The builder which contains information we might need
99         thread_num: Our thread number (0-n-1), used to decide on a
100             temporary directory. If this is -1 then there are no threads
101             and we are the (only) main process
102         mrproper: Use 'make mrproper' before each reconfigure
103         per_board_out_dir: True to build in a separate persistent directory per
104             board rather than a thread-specific directory
105         test_exception: Used for testing; True to raise an exception instead of
106             reporting the build result
107     """
108     def __init__(self, builder, thread_num, mrproper, per_board_out_dir,
109                  test_exception=False):
110         """Set up a new builder thread"""
111         threading.Thread.__init__(self)
112         self.builder = builder
113         self.thread_num = thread_num
114         self.mrproper = mrproper
115         self.per_board_out_dir = per_board_out_dir
116         self.test_exception = test_exception
117         self.toolchain = None
118
119     def make(self, commit, brd, stage, cwd, *args, **kwargs):
120         """Run 'make' on a particular commit and board.
121
122         The source code will already be checked out, so the 'commit'
123         argument is only for information.
124
125         Args:
126             commit: Commit object that is being built
127             brd: Board object that is being built
128             stage: Stage of the build. Valid stages are:
129                         mrproper - can be called to clean source
130                         config - called to configure for a board
131                         build - the main make invocation - it does the build
132             args: A list of arguments to pass to 'make'
133             kwargs: A list of keyword arguments to pass to command.run_pipe()
134
135         Returns:
136             CommandResult object
137         """
138         return self.builder.do_make(commit, brd, stage, cwd, *args,
139                 **kwargs)
140
141     def _build_args(self, brd, out_dir, out_rel_dir, work_dir, commit_upto):
142         """Set up arguments to the args list based on the settings
143
144         Args:
145             brd (Board): Board to create arguments for
146             out_dir (str): Path to output directory containing the files
147             out_rel_dir (str): Output directory relative to the current dir
148             work_dir (str): Directory to which the source will be checked out
149             commit_upto (int): Commit number to build (0...n-1)
150
151         Returns:
152             tuple:
153                 list of str: Arguments to pass to make
154                 str: Current working directory, or None if no commit
155                 str: Source directory (typically the work directory)
156         """
157         args = []
158         cwd = work_dir
159         src_dir = os.path.realpath(work_dir)
160         if not self.builder.in_tree:
161             if commit_upto is None:
162                 # In this case we are building in the original source directory
163                 # (i.e. the current directory where buildman is invoked. The
164                 # output directory is set to this thread's selected work
165                 # directory.
166                 #
167                 # Symlinks can confuse U-Boot's Makefile since we may use '..'
168                 # in our path, so remove them.
169                 real_dir = os.path.realpath(out_dir)
170                 args.append(f'O={real_dir}')
171                 cwd = None
172                 src_dir = os.getcwd()
173             else:
174                 args.append(f'O={out_rel_dir}')
175         if self.builder.verbose_build:
176             args.append('V=1')
177         else:
178             args.append('-s')
179         if self.builder.num_jobs is not None:
180             args.extend(['-j', str(self.builder.num_jobs)])
181         if self.builder.warnings_as_errors:
182             args.append('KCFLAGS=-Werror')
183             args.append('HOSTCFLAGS=-Werror')
184         if self.builder.allow_missing:
185             args.append('BINMAN_ALLOW_MISSING=1')
186         if self.builder.no_lto:
187             args.append('NO_LTO=1')
188         if self.builder.reproducible_builds:
189             args.append('SOURCE_DATE_EPOCH=0')
190         args.extend(self.builder.toolchains.GetMakeArguments(brd))
191         args.extend(self.toolchain.MakeArgs())
192         return args, cwd, src_dir
193
194     def run_commit(self, commit_upto, brd, work_dir, do_config, config_only,
195                   force_build, force_build_failures, work_in_output,
196                   adjust_cfg):
197         """Build a particular commit.
198
199         If the build is already done, and we are not forcing a build, we skip
200         the build and just return the previously-saved results.
201
202         Args:
203             commit_upto: Commit number to build (0...n-1)
204             brd: Board object to build
205             work_dir: Directory to which the source will be checked out
206             do_config: True to run a make <board>_defconfig on the source
207             config_only: Only configure the source, do not build it
208             force_build: Force a build even if one was previously done
209             force_build_failures: Force a bulid if the previous result showed
210                 failure
211             work_in_output: Use the output directory as the work directory and
212                 don't write to a separate output directory.
213             adjust_cfg (list of str): List of changes to make to .config file
214                 before building. Each is one of (where C is either CONFIG_xxx
215                 or just xxx):
216                      C to enable C
217                      ~C to disable C
218                      C=val to set the value of C (val must have quotes if C is
219                          a string Kconfig
220
221         Returns:
222             tuple containing:
223                 - CommandResult object containing the results of the build
224                 - boolean indicating whether 'make config' is still needed
225         """
226         # Create a default result - it will be overwritte by the call to
227         # self.make() below, in the event that we do a build.
228         result = command.CommandResult()
229         result.return_code = 0
230         if work_in_output or self.builder.in_tree:
231             out_rel_dir = None
232             out_dir = work_dir
233         else:
234             if self.per_board_out_dir:
235                 out_rel_dir = os.path.join('..', brd.target)
236             else:
237                 out_rel_dir = 'build'
238             out_dir = os.path.join(work_dir, out_rel_dir)
239
240         # Check if the job was already completed last time
241         done_file = self.builder.get_done_file(commit_upto, brd.target)
242         result.already_done = os.path.exists(done_file)
243         will_build = (force_build or force_build_failures or
244             not result.already_done)
245         if result.already_done:
246             # Get the return code from that build and use it
247             with open(done_file, 'r', encoding='utf-8') as outf:
248                 try:
249                     result.return_code = int(outf.readline())
250                 except ValueError:
251                     # The file may be empty due to running out of disk space.
252                     # Try a rebuild
253                     result.return_code = RETURN_CODE_RETRY
254
255             # Check the signal that the build needs to be retried
256             if result.return_code == RETURN_CODE_RETRY:
257                 will_build = True
258             elif will_build:
259                 err_file = self.builder.get_err_file(commit_upto, brd.target)
260                 if os.path.exists(err_file) and os.stat(err_file).st_size:
261                     result.stderr = 'bad'
262                 elif not force_build:
263                     # The build passed, so no need to build it again
264                     will_build = False
265
266         if will_build:
267             # We are going to have to build it. First, get a toolchain
268             if not self.toolchain:
269                 try:
270                     self.toolchain = self.builder.toolchains.Select(brd.arch)
271                 except ValueError as err:
272                     result.return_code = 10
273                     result.stdout = ''
274                     result.stderr = str(err)
275                     # TODO(sjg@chromium.org): This gets swallowed, but needs
276                     # to be reported.
277
278             if self.toolchain:
279                 # Checkout the right commit
280                 if self.builder.commits:
281                     commit = self.builder.commits[commit_upto]
282                     if self.builder.checkout:
283                         git_dir = os.path.join(work_dir, '.git')
284                         gitutil.checkout(commit.hash, git_dir, work_dir,
285                                          force=True)
286                 else:
287                     commit = 'current'
288
289                 # Set up the environment and command line
290                 env = self.toolchain.MakeEnvironment(self.builder.full_path)
291                 mkdir(out_dir)
292
293                 args, cwd, src_dir = self._build_args(brd, out_dir, out_rel_dir,
294                                                       work_dir, commit_upto)
295                 config_args = [f'{brd.target}_defconfig']
296                 config_out = io.StringIO()
297
298                 # Remove any output targets. Since we use a build directory that
299                 # was previously used by another board, it may have produced an
300                 # SPL image. If we don't remove it (i.e. see do_config and
301                 # self.mrproper below) then it will appear to be the output of
302                 # this build, even if it does not produce SPL images.
303                 for elf in BASE_ELF_FILENAMES:
304                     fname = os.path.join(out_dir, elf)
305                     if os.path.exists(fname):
306                         os.remove(fname)
307
308                 # If we need to reconfigure, do that now
309                 cfg_file = os.path.join(out_dir, '.config')
310                 cmd_list = []
311                 if do_config or adjust_cfg:
312                     if self.mrproper:
313                         result = self.make(commit, brd, 'mrproper', cwd,
314                                 'mrproper', *args, env=env)
315                         config_out.write(result.combined)
316                         cmd_list.append([self.builder.gnu_make, 'mrproper',
317                                          *args])
318                     result = self.make(commit, brd, 'config', cwd,
319                             *(args + config_args), env=env)
320                     cmd_list.append([self.builder.gnu_make] + args +
321                                     config_args)
322                     config_out.write(result.combined)
323                     do_config = False   # No need to configure next time
324                     if adjust_cfg:
325                         cfgutil.adjust_cfg_file(cfg_file, adjust_cfg)
326                 if result.return_code == 0:
327                     if config_only:
328                         args.append('cfg')
329                     result = self.make(commit, brd, 'build', cwd, *args,
330                             env=env)
331                     cmd_list.append([self.builder.gnu_make] + args)
332                     if (result.return_code == 2 and
333                         ('Some images are invalid' in result.stderr)):
334                         # This is handled later by the check for output in
335                         # stderr
336                         result.return_code = 0
337                     if adjust_cfg:
338                         errs = cfgutil.check_cfg_file(cfg_file, adjust_cfg)
339                         if errs:
340                             result.stderr += errs
341                             result.return_code = 1
342                 result.stderr = result.stderr.replace(src_dir + '/', '')
343                 if self.builder.verbose_build:
344                     result.stdout = config_out.getvalue() + result.stdout
345                 result.cmd_list = cmd_list
346             else:
347                 result.return_code = 1
348                 result.stderr = f'No tool chain for {brd.arch}\n'
349             result.already_done = False
350
351         result.toolchain = self.toolchain
352         result.brd = brd
353         result.commit_upto = commit_upto
354         result.out_dir = out_dir
355         return result, do_config
356
357     def _write_result(self, result, keep_outputs, work_in_output):
358         """Write a built result to the output directory.
359
360         Args:
361             result: CommandResult object containing result to write
362             keep_outputs: True to store the output binaries, False
363                 to delete them
364             work_in_output: Use the output directory as the work directory and
365                 don't write to a separate output directory.
366         """
367         # If we think this might have been aborted with Ctrl-C, record the
368         # failure but not that we are 'done' with this board. A retry may fix
369         # it.
370         maybe_aborted = result.stderr and 'No child processes' in result.stderr
371
372         if result.return_code >= 0 and result.already_done:
373             return
374
375         # Write the output and stderr
376         output_dir = self.builder.get_output_dir(result.commit_upto)
377         mkdir(output_dir)
378         build_dir = self.builder.get_build_dir(result.commit_upto,
379                 result.brd.target)
380         mkdir(build_dir)
381
382         outfile = os.path.join(build_dir, 'log')
383         with open(outfile, 'w', encoding='utf-8') as outf:
384             if result.stdout:
385                 outf.write(result.stdout)
386
387         errfile = self.builder.get_err_file(result.commit_upto,
388                 result.brd.target)
389         if result.stderr:
390             with open(errfile, 'w', encoding='utf-8') as outf:
391                 outf.write(result.stderr)
392         elif os.path.exists(errfile):
393             os.remove(errfile)
394
395         # Fatal error
396         if result.return_code < 0:
397             return
398
399         if result.toolchain:
400             # Write the build result and toolchain information.
401             done_file = self.builder.get_done_file(result.commit_upto,
402                     result.brd.target)
403             with open(done_file, 'w', encoding='utf-8') as outf:
404                 if maybe_aborted:
405                     # Special code to indicate we need to retry
406                     outf.write(f'{RETURN_CODE_RETRY}')
407                 else:
408                     outf.write(f'{result.return_code}')
409             with open(os.path.join(build_dir, 'toolchain'), 'w',
410                       encoding='utf-8') as outf:
411                 print('gcc', result.toolchain.gcc, file=outf)
412                 print('path', result.toolchain.path, file=outf)
413                 print('cross', result.toolchain.cross, file=outf)
414                 print('arch', result.toolchain.arch, file=outf)
415                 outf.write(f'{result.return_code}')
416
417             # Write out the image and function size information and an objdump
418             env = result.toolchain.MakeEnvironment(self.builder.full_path)
419             with open(os.path.join(build_dir, 'out-env'), 'wb') as outf:
420                 for var in sorted(env.keys()):
421                     outf.write(b'%s="%s"' % (var, env[var]))
422
423             with open(os.path.join(build_dir, 'out-cmd'), 'w',
424                       encoding='utf-8') as outf:
425                 for cmd in result.cmd_list:
426                     print(' '.join(cmd), file=outf)
427
428             lines = []
429             for fname in BASE_ELF_FILENAMES:
430                 cmd = [f'{self.toolchain.cross}nm', '--size-sort', fname]
431                 nm_result = command.run_pipe([cmd], capture=True,
432                         capture_stderr=True, cwd=result.out_dir,
433                         raise_on_error=False, env=env)
434                 if nm_result.stdout:
435                     nm_fname = self.builder.get_func_sizes_file(
436                         result.commit_upto, result.brd.target, fname)
437                     with open(nm_fname, 'w', encoding='utf-8') as outf:
438                         print(nm_result.stdout, end=' ', file=outf)
439
440                 cmd = [f'{self.toolchain.cross}objdump', '-h', fname]
441                 dump_result = command.run_pipe([cmd], capture=True,
442                         capture_stderr=True, cwd=result.out_dir,
443                         raise_on_error=False, env=env)
444                 rodata_size = ''
445                 if dump_result.stdout:
446                     objdump = self.builder.get_objdump_file(result.commit_upto,
447                                     result.brd.target, fname)
448                     with open(objdump, 'w', encoding='utf-8') as outf:
449                         print(dump_result.stdout, end=' ', file=outf)
450                     for line in dump_result.stdout.splitlines():
451                         fields = line.split()
452                         if len(fields) > 5 and fields[1] == '.rodata':
453                             rodata_size = fields[2]
454
455                 cmd = [f'{self.toolchain.cross}size', fname]
456                 size_result = command.run_pipe([cmd], capture=True,
457                         capture_stderr=True, cwd=result.out_dir,
458                         raise_on_error=False, env=env)
459                 if size_result.stdout:
460                     lines.append(size_result.stdout.splitlines()[1] + ' ' +
461                                  rodata_size)
462
463             # Extract the environment from U-Boot and dump it out
464             cmd = [f'{self.toolchain.cross}objcopy', '-O', 'binary',
465                    '-j', '.rodata.default_environment',
466                    'env/built-in.o', 'uboot.env']
467             command.run_pipe([cmd], capture=True,
468                             capture_stderr=True, cwd=result.out_dir,
469                             raise_on_error=False, env=env)
470             if not work_in_output:
471                 self.copy_files(result.out_dir, build_dir, '', ['uboot.env'])
472
473             # Write out the image sizes file. This is similar to the output
474             # of binutil's 'size' utility, but it omits the header line and
475             # adds an additional hex value at the end of each line for the
476             # rodata size
477             if lines:
478                 sizes = self.builder.get_sizes_file(result.commit_upto,
479                                 result.brd.target)
480                 with open(sizes, 'w', encoding='utf-8') as outf:
481                     print('\n'.join(lines), file=outf)
482
483         if not work_in_output:
484             # Write out the configuration files, with a special case for SPL
485             for dirname in ['', 'spl', 'tpl']:
486                 self.copy_files(
487                     result.out_dir, build_dir, dirname,
488                     ['u-boot.cfg', 'spl/u-boot-spl.cfg', 'tpl/u-boot-tpl.cfg',
489                      '.config', 'include/autoconf.mk',
490                      'include/generated/autoconf.h'])
491
492             # Now write the actual build output
493             if keep_outputs:
494                 self.copy_files(
495                     result.out_dir, build_dir, '',
496                     ['u-boot*', '*.bin', '*.map', '*.img', 'MLO', 'SPL',
497                      'include/autoconf.mk', 'spl/u-boot-spl*'])
498
499     def copy_files(self, out_dir, build_dir, dirname, patterns):
500         """Copy files from the build directory to the output.
501
502         Args:
503             out_dir: Path to output directory containing the files
504             build_dir: Place to copy the files
505             dirname: Source directory, '' for normal U-Boot, 'spl' for SPL
506             patterns: A list of filenames (strings) to copy, each relative
507                to the build directory
508         """
509         for pattern in patterns:
510             file_list = glob.glob(os.path.join(out_dir, dirname, pattern))
511             for fname in file_list:
512                 target = os.path.basename(fname)
513                 if dirname:
514                     base, ext = os.path.splitext(target)
515                     if ext:
516                         target = f'{base}-{dirname}{ext}'
517                 shutil.copy(fname, os.path.join(build_dir, target))
518
519     def _send_result(self, result):
520         """Send a result to the builder for processing
521
522         Args:
523             result: CommandResult object containing the results of the build
524
525         Raises:
526             ValueError if self.test_exception is true (for testing)
527         """
528         if self.test_exception:
529             raise ValueError('test exception')
530         if self.thread_num != -1:
531             self.builder.out_queue.put(result)
532         else:
533             self.builder.process_result(result)
534
535     def run_job(self, job):
536         """Run a single job
537
538         A job consists of a building a list of commits for a particular board.
539
540         Args:
541             job: Job to build
542
543         Returns:
544             List of Result objects
545         """
546         brd = job.brd
547         work_dir = self.builder.get_thread_dir(self.thread_num)
548         self.toolchain = None
549         if job.commits:
550             # Run 'make board_defconfig' on the first commit
551             do_config = True
552             commit_upto  = 0
553             force_build = False
554             for commit_upto in range(0, len(job.commits), job.step):
555                 result, request_config = self.run_commit(commit_upto, brd,
556                         work_dir, do_config, self.builder.config_only,
557                         force_build or self.builder.force_build,
558                         self.builder.force_build_failures,
559                         job.work_in_output, job.adjust_cfg)
560                 failed = result.return_code or result.stderr
561                 did_config = do_config
562                 if failed and not do_config:
563                     # If our incremental build failed, try building again
564                     # with a reconfig.
565                     if self.builder.force_config_on_failure:
566                         result, request_config = self.run_commit(commit_upto,
567                             brd, work_dir, True, False, True, False,
568                             job.work_in_output, job.adjust_cfg)
569                         did_config = True
570                 if not self.builder.force_reconfig:
571                     do_config = request_config
572
573                 # If we built that commit, then config is done. But if we got
574                 # an warning, reconfig next time to force it to build the same
575                 # files that created warnings this time. Otherwise an
576                 # incremental build may not build the same file, and we will
577                 # think that the warning has gone away.
578                 # We could avoid this by using -Werror everywhere...
579                 # For errors, the problem doesn't happen, since presumably
580                 # the build stopped and didn't generate output, so will retry
581                 # that file next time. So we could detect warnings and deal
582                 # with them specially here. For now, we just reconfigure if
583                 # anything goes work.
584                 # Of course this is substantially slower if there are build
585                 # errors/warnings (e.g. 2-3x slower even if only 10% of builds
586                 # have problems).
587                 if (failed and not result.already_done and not did_config and
588                         self.builder.force_config_on_failure):
589                     # If this build failed, try the next one with a
590                     # reconfigure.
591                     # Sometimes if the board_config.h file changes it can mess
592                     # with dependencies, and we get:
593                     # make: *** No rule to make target `include/autoconf.mk',
594                     #     needed by `depend'.
595                     do_config = True
596                     force_build = True
597                 else:
598                     force_build = False
599                     if self.builder.force_config_on_failure:
600                         if failed:
601                             do_config = True
602                     result.commit_upto = commit_upto
603                     if result.return_code < 0:
604                         raise ValueError('Interrupt')
605
606                 # We have the build results, so output the result
607                 self._write_result(result, job.keep_outputs, job.work_in_output)
608                 self._send_result(result)
609         else:
610             # Just build the currently checked-out build
611             result, request_config = self.run_commit(None, brd, work_dir, True,
612                         self.builder.config_only, True,
613                         self.builder.force_build_failures, job.work_in_output,
614                         job.adjust_cfg)
615             result.commit_upto = 0
616             self._write_result(result, job.keep_outputs, job.work_in_output)
617             self._send_result(result)
618
619     def run(self):
620         """Our thread's run function
621
622         This thread picks a job from the queue, runs it, and then goes to the
623         next job.
624         """
625         while True:
626             job = self.builder.queue.get()
627             try:
628                 self.run_job(job)
629             except Exception as exc:
630                 print('Thread exception (use -T0 to run without threads):',
631                       exc)
632                 self.builder.thread_exceptions.append(exc)
633             self.builder.queue.task_done()