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