1# SPDX-License-Identifier: GPL-2.0 2# 3# Runs UML kernel, collects output, and handles errors. 4# 5# Copyright (C) 2019, Google LLC. 6# Author: Felix Guo <felixguoxiuping@gmail.com> 7# Author: Brendan Higgins <brendanhiggins@google.com> 8 9import importlib.abc 10import importlib.util 11import logging 12import subprocess 13import os 14import shlex 15import shutil 16import signal 17import threading 18from typing import Iterator, List, Optional, Tuple 19 20import kunit_config 21from kunit_printer import stdout 22import qemu_config 23 24KCONFIG_PATH = '.config' 25KUNITCONFIG_PATH = '.kunitconfig' 26OLD_KUNITCONFIG_PATH = 'last_used_kunitconfig' 27DEFAULT_KUNITCONFIG_PATH = 'tools/testing/kunit/configs/default.config' 28BROKEN_ALLCONFIG_PATH = 'tools/testing/kunit/configs/broken_on_uml.config' 29UML_KCONFIG_PATH = 'tools/testing/kunit/configs/arch_uml.config' 30OUTFILE_PATH = 'test.log' 31ABS_TOOL_PATH = os.path.abspath(os.path.dirname(__file__)) 32QEMU_CONFIGS_DIR = os.path.join(ABS_TOOL_PATH, 'qemu_configs') 33 34class ConfigError(Exception): 35 """Represents an error trying to configure the Linux kernel.""" 36 37 38class BuildError(Exception): 39 """Represents an error trying to build the Linux kernel.""" 40 41 42class LinuxSourceTreeOperations: 43 """An abstraction over command line operations performed on a source tree.""" 44 45 def __init__(self, linux_arch: str, cross_compile: Optional[str]): 46 self._linux_arch = linux_arch 47 self._cross_compile = cross_compile 48 49 def make_mrproper(self) -> None: 50 try: 51 subprocess.check_output(['make', 'mrproper'], stderr=subprocess.STDOUT) 52 except OSError as e: 53 raise ConfigError('Could not call make command: ' + str(e)) 54 except subprocess.CalledProcessError as e: 55 raise ConfigError(e.output.decode()) 56 57 def make_arch_config(self, base_kunitconfig: kunit_config.Kconfig) -> kunit_config.Kconfig: 58 return base_kunitconfig 59 60 def make_allyesconfig(self, build_dir: str, make_options) -> None: 61 raise ConfigError('Only the "um" arch is supported for alltests') 62 63 def make_olddefconfig(self, build_dir: str, make_options) -> None: 64 command = ['make', 'ARCH=' + self._linux_arch, 'O=' + build_dir, 'olddefconfig'] 65 if self._cross_compile: 66 command += ['CROSS_COMPILE=' + self._cross_compile] 67 if make_options: 68 command.extend(make_options) 69 print('Populating config with:\n$', ' '.join(command)) 70 try: 71 subprocess.check_output(command, stderr=subprocess.STDOUT) 72 except OSError as e: 73 raise ConfigError('Could not call make command: ' + str(e)) 74 except subprocess.CalledProcessError as e: 75 raise ConfigError(e.output.decode()) 76 77 def make(self, jobs, build_dir: str, make_options) -> None: 78 command = ['make', 'ARCH=' + self._linux_arch, 'O=' + build_dir, '--jobs=' + str(jobs)] 79 if make_options: 80 command.extend(make_options) 81 if self._cross_compile: 82 command += ['CROSS_COMPILE=' + self._cross_compile] 83 print('Building with:\n$', ' '.join(command)) 84 try: 85 proc = subprocess.Popen(command, 86 stderr=subprocess.PIPE, 87 stdout=subprocess.DEVNULL) 88 except OSError as e: 89 raise BuildError('Could not call execute make: ' + str(e)) 90 except subprocess.CalledProcessError as e: 91 raise BuildError(e.output) 92 _, stderr = proc.communicate() 93 if proc.returncode != 0: 94 raise BuildError(stderr.decode()) 95 if stderr: # likely only due to build warnings 96 print(stderr.decode()) 97 98 def start(self, params: List[str], build_dir: str) -> subprocess.Popen: 99 raise RuntimeError('not implemented!') 100 101 102class LinuxSourceTreeOperationsQemu(LinuxSourceTreeOperations): 103 104 def __init__(self, qemu_arch_params: qemu_config.QemuArchParams, cross_compile: Optional[str]): 105 super().__init__(linux_arch=qemu_arch_params.linux_arch, 106 cross_compile=cross_compile) 107 self._kconfig = qemu_arch_params.kconfig 108 self._qemu_arch = qemu_arch_params.qemu_arch 109 self._kernel_path = qemu_arch_params.kernel_path 110 self._kernel_command_line = qemu_arch_params.kernel_command_line + ' kunit_shutdown=reboot' 111 self._extra_qemu_params = qemu_arch_params.extra_qemu_params 112 113 def make_arch_config(self, base_kunitconfig: kunit_config.Kconfig) -> kunit_config.Kconfig: 114 kconfig = kunit_config.parse_from_string(self._kconfig) 115 kconfig.merge_in_entries(base_kunitconfig) 116 return kconfig 117 118 def start(self, params: List[str], build_dir: str) -> subprocess.Popen: 119 kernel_path = os.path.join(build_dir, self._kernel_path) 120 qemu_command = ['qemu-system-' + self._qemu_arch, 121 '-nodefaults', 122 '-m', '1024', 123 '-kernel', kernel_path, 124 '-append', ' '.join(params + [self._kernel_command_line]), 125 '-no-reboot', 126 '-nographic', 127 '-serial', 'stdio'] + self._extra_qemu_params 128 # Note: shlex.join() does what we want, but requires python 3.8+. 129 print('Running tests with:\n$', ' '.join(shlex.quote(arg) for arg in qemu_command)) 130 return subprocess.Popen(qemu_command, 131 stdin=subprocess.PIPE, 132 stdout=subprocess.PIPE, 133 stderr=subprocess.STDOUT, 134 text=True, errors='backslashreplace') 135 136class LinuxSourceTreeOperationsUml(LinuxSourceTreeOperations): 137 """An abstraction over command line operations performed on a source tree.""" 138 139 def __init__(self, cross_compile=None): 140 super().__init__(linux_arch='um', cross_compile=cross_compile) 141 142 def make_arch_config(self, base_kunitconfig: kunit_config.Kconfig) -> kunit_config.Kconfig: 143 kconfig = kunit_config.parse_file(UML_KCONFIG_PATH) 144 kconfig.merge_in_entries(base_kunitconfig) 145 return kconfig 146 147 def make_allyesconfig(self, build_dir: str, make_options) -> None: 148 stdout.print_with_timestamp( 149 'Enabling all CONFIGs for UML...') 150 command = ['make', 'ARCH=um', 'O=' + build_dir, 'allyesconfig'] 151 if make_options: 152 command.extend(make_options) 153 process = subprocess.Popen( 154 command, 155 stdout=subprocess.DEVNULL, 156 stderr=subprocess.STDOUT) 157 process.wait() 158 stdout.print_with_timestamp( 159 'Disabling broken configs to run KUnit tests...') 160 161 with open(get_kconfig_path(build_dir), 'a') as config: 162 with open(BROKEN_ALLCONFIG_PATH, 'r') as disable: 163 config.write(disable.read()) 164 stdout.print_with_timestamp( 165 'Starting Kernel with all configs takes a few minutes...') 166 167 def start(self, params: List[str], build_dir: str) -> subprocess.Popen: 168 """Runs the Linux UML binary. Must be named 'linux'.""" 169 linux_bin = os.path.join(build_dir, 'linux') 170 params.extend(['mem=1G', 'console=tty', 'kunit_shutdown=halt']) 171 return subprocess.Popen([linux_bin] + params, 172 stdin=subprocess.PIPE, 173 stdout=subprocess.PIPE, 174 stderr=subprocess.STDOUT, 175 text=True, errors='backslashreplace') 176 177def get_kconfig_path(build_dir: str) -> str: 178 return os.path.join(build_dir, KCONFIG_PATH) 179 180def get_kunitconfig_path(build_dir: str) -> str: 181 return os.path.join(build_dir, KUNITCONFIG_PATH) 182 183def get_old_kunitconfig_path(build_dir: str) -> str: 184 return os.path.join(build_dir, OLD_KUNITCONFIG_PATH) 185 186def get_parsed_kunitconfig(build_dir: str, 187 kunitconfig_paths: Optional[List[str]]=None) -> kunit_config.Kconfig: 188 if not kunitconfig_paths: 189 path = get_kunitconfig_path(build_dir) 190 if not os.path.exists(path): 191 shutil.copyfile(DEFAULT_KUNITCONFIG_PATH, path) 192 return kunit_config.parse_file(path) 193 194 merged = kunit_config.Kconfig() 195 196 for path in kunitconfig_paths: 197 if os.path.isdir(path): 198 path = os.path.join(path, KUNITCONFIG_PATH) 199 if not os.path.exists(path): 200 raise ConfigError(f'Specified kunitconfig ({path}) does not exist') 201 202 partial = kunit_config.parse_file(path) 203 diff = merged.conflicting_options(partial) 204 if diff: 205 diff_str = '\n\n'.join(f'{a}\n vs from {path}\n{b}' for a, b in diff) 206 raise ConfigError(f'Multiple values specified for {len(diff)} options in kunitconfig:\n{diff_str}') 207 merged.merge_in_entries(partial) 208 return merged 209 210def get_outfile_path(build_dir: str) -> str: 211 return os.path.join(build_dir, OUTFILE_PATH) 212 213def _default_qemu_config_path(arch: str) -> str: 214 config_path = os.path.join(QEMU_CONFIGS_DIR, arch + '.py') 215 if os.path.isfile(config_path): 216 return config_path 217 218 options = [f[:-3] for f in os.listdir(QEMU_CONFIGS_DIR) if f.endswith('.py')] 219 raise ConfigError(arch + ' is not a valid arch, options are ' + str(sorted(options))) 220 221def _get_qemu_ops(config_path: str, 222 extra_qemu_args: Optional[List[str]], 223 cross_compile: Optional[str]) -> Tuple[str, LinuxSourceTreeOperations]: 224 # The module name/path has very little to do with where the actual file 225 # exists (I learned this through experimentation and could not find it 226 # anywhere in the Python documentation). 227 # 228 # Bascially, we completely ignore the actual file location of the config 229 # we are loading and just tell Python that the module lives in the 230 # QEMU_CONFIGS_DIR for import purposes regardless of where it actually 231 # exists as a file. 232 module_path = '.' + os.path.join(os.path.basename(QEMU_CONFIGS_DIR), os.path.basename(config_path)) 233 spec = importlib.util.spec_from_file_location(module_path, config_path) 234 assert spec is not None 235 config = importlib.util.module_from_spec(spec) 236 # See https://github.com/python/typeshed/pull/2626 for context. 237 assert isinstance(spec.loader, importlib.abc.Loader) 238 spec.loader.exec_module(config) 239 240 if not hasattr(config, 'QEMU_ARCH'): 241 raise ValueError('qemu_config module missing "QEMU_ARCH": ' + config_path) 242 params: qemu_config.QemuArchParams = config.QEMU_ARCH # type: ignore 243 if extra_qemu_args: 244 params.extra_qemu_params.extend(extra_qemu_args) 245 return params.linux_arch, LinuxSourceTreeOperationsQemu( 246 params, cross_compile=cross_compile) 247 248class LinuxSourceTree: 249 """Represents a Linux kernel source tree with KUnit tests.""" 250 251 def __init__( 252 self, 253 build_dir: str, 254 kunitconfig_paths: Optional[List[str]]=None, 255 kconfig_add: Optional[List[str]]=None, 256 arch=None, 257 cross_compile=None, 258 qemu_config_path=None, 259 extra_qemu_args=None) -> None: 260 signal.signal(signal.SIGINT, self.signal_handler) 261 if qemu_config_path: 262 self._arch, self._ops = _get_qemu_ops(qemu_config_path, extra_qemu_args, cross_compile) 263 else: 264 self._arch = 'um' if arch is None else arch 265 if self._arch == 'um': 266 self._ops = LinuxSourceTreeOperationsUml(cross_compile=cross_compile) 267 else: 268 qemu_config_path = _default_qemu_config_path(self._arch) 269 _, self._ops = _get_qemu_ops(qemu_config_path, extra_qemu_args, cross_compile) 270 271 self._kconfig = get_parsed_kunitconfig(build_dir, kunitconfig_paths) 272 if kconfig_add: 273 kconfig = kunit_config.parse_from_string('\n'.join(kconfig_add)) 274 self._kconfig.merge_in_entries(kconfig) 275 276 def arch(self) -> str: 277 return self._arch 278 279 def clean(self) -> bool: 280 try: 281 self._ops.make_mrproper() 282 except ConfigError as e: 283 logging.error(e) 284 return False 285 return True 286 287 def validate_config(self, build_dir: str) -> bool: 288 kconfig_path = get_kconfig_path(build_dir) 289 validated_kconfig = kunit_config.parse_file(kconfig_path) 290 if self._kconfig.is_subset_of(validated_kconfig): 291 return True 292 missing = set(self._kconfig.as_entries()) - set(validated_kconfig.as_entries()) 293 message = 'Not all Kconfig options selected in kunitconfig were in the generated .config.\n' \ 294 'This is probably due to unsatisfied dependencies.\n' \ 295 'Missing: ' + ', '.join(str(e) for e in missing) 296 if self._arch == 'um': 297 message += '\nNote: many Kconfig options aren\'t available on UML. You can try running ' \ 298 'on a different architecture with something like "--arch=x86_64".' 299 logging.error(message) 300 return False 301 302 def build_config(self, build_dir: str, make_options) -> bool: 303 kconfig_path = get_kconfig_path(build_dir) 304 if build_dir and not os.path.exists(build_dir): 305 os.mkdir(build_dir) 306 try: 307 self._kconfig = self._ops.make_arch_config(self._kconfig) 308 self._kconfig.write_to_file(kconfig_path) 309 self._ops.make_olddefconfig(build_dir, make_options) 310 except ConfigError as e: 311 logging.error(e) 312 return False 313 if not self.validate_config(build_dir): 314 return False 315 316 old_path = get_old_kunitconfig_path(build_dir) 317 if os.path.exists(old_path): 318 os.remove(old_path) # write_to_file appends to the file 319 self._kconfig.write_to_file(old_path) 320 return True 321 322 def _kunitconfig_changed(self, build_dir: str) -> bool: 323 old_path = get_old_kunitconfig_path(build_dir) 324 if not os.path.exists(old_path): 325 return True 326 327 old_kconfig = kunit_config.parse_file(old_path) 328 return old_kconfig != self._kconfig 329 330 def build_reconfig(self, build_dir: str, make_options) -> bool: 331 """Creates a new .config if it is not a subset of the .kunitconfig.""" 332 kconfig_path = get_kconfig_path(build_dir) 333 if not os.path.exists(kconfig_path): 334 print('Generating .config ...') 335 return self.build_config(build_dir, make_options) 336 337 existing_kconfig = kunit_config.parse_file(kconfig_path) 338 self._kconfig = self._ops.make_arch_config(self._kconfig) 339 340 if self._kconfig.is_subset_of(existing_kconfig) and not self._kunitconfig_changed(build_dir): 341 return True 342 print('Regenerating .config ...') 343 os.remove(kconfig_path) 344 return self.build_config(build_dir, make_options) 345 346 def build_kernel(self, alltests, jobs, build_dir: str, make_options) -> bool: 347 try: 348 if alltests: 349 self._ops.make_allyesconfig(build_dir, make_options) 350 self._ops.make_olddefconfig(build_dir, make_options) 351 self._ops.make(jobs, build_dir, make_options) 352 except (ConfigError, BuildError) as e: 353 logging.error(e) 354 return False 355 return self.validate_config(build_dir) 356 357 def run_kernel(self, args=None, build_dir='', filter_glob='', timeout=None) -> Iterator[str]: 358 if not args: 359 args = [] 360 if filter_glob: 361 args.append('kunit.filter_glob='+filter_glob) 362 363 process = self._ops.start(args, build_dir) 364 assert process.stdout is not None # tell mypy it's set 365 366 # Enforce the timeout in a background thread. 367 def _wait_proc(): 368 try: 369 process.wait(timeout=timeout) 370 except Exception as e: 371 print(e) 372 process.terminate() 373 process.wait() 374 waiter = threading.Thread(target=_wait_proc) 375 waiter.start() 376 377 output = open(get_outfile_path(build_dir), 'w') 378 try: 379 # Tee the output to the file and to our caller in real time. 380 for line in process.stdout: 381 output.write(line) 382 yield line 383 # This runs even if our caller doesn't consume every line. 384 finally: 385 # Flush any leftover output to the file 386 output.write(process.stdout.read()) 387 output.close() 388 process.stdout.close() 389 390 waiter.join() 391 subprocess.call(['stty', 'sane']) 392 393 def signal_handler(self, unused_sig, unused_frame) -> None: 394 logging.error('Build interruption occurred. Cleaning console.') 395 subprocess.call(['stty', 'sane']) 396