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