From 125abbf9bcae1b1342c3c76bafdb34371e4c336c Mon Sep 17 00:00:00 2001 From: Metin Kaya Date: Mon, 15 Jan 2024 12:53:45 +0000 Subject: [PATCH] target: Implement target runner classes Add support for launching emulated targets on QEMU. The base class ``TargetRunner`` has groundwork for target runners like ``QEMUTargetRunner``. ``TargetRunner`` is a contextmanager which starts runner process (e.g., QEMU), makes sure the target is accessible over SSH (if ``connect=True``), and terminates the runner process once it's done. The other newly introduced ``QEMUTargetRunner`` class: - performs sanity checks to ensure QEMU executable, kernel, and initrd images exist, - builds QEMU parameters properly, - creates ``Target`` object, - and lets ``TargetRunner`` manage the QEMU instance. Also add a new test case in ``tests/test_target.py`` to ensure devlib can run a QEMU target and execute some basic commands on it. While we are in neighborhood, fix a typo in ``Target.setup()``. Signed-off-by: Metin Kaya --- devlib/__init__.py | 2 +- devlib/target.py | 248 +++++++++++++++++++++++++++++++++++++- tests/target_configs.yaml | 10 ++ tests/test_target.py | 12 +- 4 files changed, 268 insertions(+), 4 deletions(-) diff --git a/devlib/__init__.py b/devlib/__init__.py index e496299b1..178429457 100644 --- a/devlib/__init__.py +++ b/devlib/__init__.py @@ -19,7 +19,7 @@ from devlib.target import ( Target, LinuxTarget, AndroidTarget, LocalLinuxTarget, - ChromeOsTarget, + ChromeOsTarget, QEMUTargetRunner, ) from devlib.host import ( diff --git a/devlib/target.py b/devlib/target.py index f7d009059..1d6b59c1a 100644 --- a/devlib/target.py +++ b/devlib/target.py @@ -25,6 +25,7 @@ import time import logging import posixpath +import signal import subprocess import tarfile import tempfile @@ -39,6 +40,7 @@ from past.types import basestring from numbers import Number from shlex import quote +from platform import machine try: from collections.abc import Mapping except ImportError: @@ -57,7 +59,7 @@ from devlib.utils.ssh import SshConnection from devlib.utils.android import AdbConnection, AndroidProperties, LogcatMonitor, adb_command, INTENT_FLAGS from devlib.utils.misc import memoized, isiterable, convert_new_lines, groupby_value -from devlib.utils.misc import commonprefix, merge_lists +from devlib.utils.misc import get_subprocess, commonprefix, merge_lists, which from devlib.utils.misc import ABI_MAP, get_cpu_name, ranges_to_list from devlib.utils.misc import batch_contextmanager, tls_property, _BoundTLSProperty, nullcontext from devlib.utils.misc import safe_extract @@ -494,7 +496,7 @@ async def setup(self, executables=None): # Check for platform dependent setup procedures self.platform.setup(self) - # Initialize modules which requires Buxybox (e.g. shutil dependent tasks) + # Initialize modules which requires Busybox (e.g. shutil dependent tasks) self._update_modules('setup') await self.execute.asyn('mkdir -p {}'.format(quote(self._file_transfer_cache))) @@ -2924,6 +2926,248 @@ def _resolve_paths(self): self.executables_directory = '/tmp/devlib-target/bin' +class TargetRunner: + ''' + A generic class for interacting with targets runners. + + It mainly aims to provide framework support for QEMU like target runners + (e.g., :class:`QEMUTargetRunner`). + ''' + + def __init__(self, + runner_cmd, + target, + connect=True, + boot_timeout=60): + ''' + Initialization procedure for :class:`TargetRunner` objects. + + Args: + runner_cmd (str): The command to start runner process + (e.g., ``qemu-system-aarch64 -kernel Image -append "console=ttyAMA0" ...``). + target (Target): Specifies type of target per :class:`Target` based classes. + connect (bool, optional): Specifies if :class:`TargetRunner` should try to connect + target after launching it. Defaults to ``True``. + boot_timeout (int, optional): Timeout for target's being ready for SSH access. + Defaults to ``60`` seconds. + + Raises: + HostError: if it cannot execute runner command successfully. + ''' + + self.boot_timeout = boot_timeout + self.target = target + + self.logger = logging.getLogger(self.__class__.__name__) + + self.logger.info('runner_cmd: %s', runner_cmd) + + try: + self.runner_process = get_subprocess(list(runner_cmd.split())) + except Exception as ex: + raise HostError(f'Error while running "{runner_cmd}": {ex}') from ex + + if connect: + self.wait_boot_complete() + + def __enter__(self): + ''' + Complementary method for contextmanager. + + Returns: + TargetRunner: Self object. + ''' + + return self + + def __exit__(self, *_): + ''' + Exit routine for contextmanager. + + Ensure :attr:`TargetRunner.runner_process` is terminated on exit. + ''' + + self.terminate_target() + + def wait_boot_complete(self): + ''' + Wait for target OS to finish boot up and become accessible over SSH in at most + :attr:`TargetRunner.boot_timeout` seconds. + + Raises: + TargetStableError: In case of timeout. + ''' + + start_time = time.time() + elapsed = 0 + while self.boot_timeout >= elapsed: + try: + self.target.connect(timeout=self.boot_timeout - elapsed) + self.logger.info('Target is ready.') + return + # pylint: disable=broad-except + except BaseException as ex: + self.logger.info('Cannot connect target: %s', ex) + + time.sleep(1) + elapsed = time.time() - start_time + + self.terminate_target() + raise TargetStableError(f'Target is inaccessible for {self.boot_timeout} seconds!') + + def terminate_target(self): + ''' + Terminate :attr:`TargetRunner.runner_process`. + ''' + + if self.runner_process is None: + return + + try: + self.runner_process.stdin.close() + self.runner_process.stdout.close() + self.runner_process.stderr.close() + + if self.runner_process.poll() is None: + self.logger.debug('Terminating target runner...') + os.killpg(self.runner_process.pid, signal.SIGTERM) + # Wait 3 seconds before killing the runner. + self.runner_process.wait(timeout=3) + except subprocess.TimeoutExpired: + self.logger.info('Killing target runner...') + os.killpg(self.runner_process.pid, signal.SIGKILL) + + +class QEMUTargetRunner(TargetRunner): + ''' + Class for interacting with QEMU runners. + + :class:`QEMUTargetRunner` is a subclass of :class:`TargetRunner` which performs necessary + groundwork for launching a guest OS on QEMU. + ''' + + def __init__(self, + qemu_params, + connection_settings=None, + # pylint: disable=unnecessary-lambda + make_target=lambda **kwargs: LinuxTarget(**kwargs), + **args): + ''' + Init procedure for :class:`QEMUTargetRunner` class. + + Args: + qemu_params (dict): A dictionary which has QEMU related parameters. The full list of + QEMU parameters is below: + * ``kernel_image``: This is the location of kernel image (e.g., ``Image``) which + will be used as target's kernel. + + * ``arch``: Architecture type. Defaults to ``aarch64``. + + * ``cpu_types``: List of CPU ids for QEMU. The list only contains ``cortex-a72`` by + default. This parameter is valid for Arm architectures only. + + * ``initrd_image``: This points to the location of initrd image (e.g., + ``rootfs.cpio.xz``) which will be used as target's root filesystem if kernel + does not include one already. + + * ``mem_size``: Size of guest memory in MiB. + + * ``num_cores``: Number of CPU cores. Guest will have ``2`` cores by default. + + * ``num_threads``: Number of CPU threads. Set to ``2`` by defaults. + + * ``cmdline``: Kernel command line parameter. It only specifies console device in + default (i.e., ``console=ttyAMA0``) which is valid for Arm architectures. + May be changed to ``ttyS0`` for x86 platforms. + + * ``enable_kvm``: Specifies if KVM will be used as accelerator in QEMU or not. + Enabled by default if host architecture matches with target's for improving + QEMU performance. + + connection_settings (dict, optional): The dictionary which stores connection settings + of :attr:`Target.connection_settings`. Defaults to ``None``. + make_target (func, optional): Lambda function for creating :class:`Target` based + object. Defaults to :func:`lambda **kwargs: LinuxTarget(**kwargs)`. + args (optional): Arguments for :class:`TargetRunner` class. + + Raises: + FileNotFoundError: if QEMU executable, kernel or initrd image cannot be found. + ''' + + connection_settings_default = { + 'host': '127.0.0.1', + 'port': 8022, + 'username': 'root', + 'password': 'root', + 'strict_host_check': False, + } + + # Update default connection settings with :param:`connection_settings` (if exists). + if connection_settings is not None: + connection_settings_default = { **connection_settings_default, **connection_settings } + + qemu_default_args = { + 'kernel_image': '', + 'arch': 'aarch64', + 'cpu_type': 'cortex-a72', + 'initrd_image': '', + 'mem_size': 512, + 'num_cores': 2, + 'num_threads': 2, + 'cmdline': 'console=ttyAMA0', + 'enable_kvm': True, + } + + # Update default QEMU parameters with :param:`qemu_params`. + qemu_default_args.update( + (key, value) + for key, value in qemu_params.items() + if key in qemu_default_args + ) + + qemu_executable = f'qemu-system-{qemu_default_args["arch"]}' + qemu_path = which(qemu_executable) + if qemu_path is None: + raise FileNotFoundError(f'Cannot find {qemu_executable} executable!') + + if not os.path.exists(qemu_default_args["kernel_image"]): + raise FileNotFoundError(f'{qemu_default_args["kernel_image"]} does not exist!') + + # pylint: disable=consider-using-f-string + qemu_cmd = '''\ +{} -kernel {} -append "{}" -m {} -smp cores={},threads={} -netdev user,id=net0,hostfwd=tcp::{}-:22 \ +-device virtio-net-pci,netdev=net0 --nographic\ +'''.format( + qemu_path, + qemu_default_args["kernel_image"], + qemu_default_args["cmdline"], + qemu_default_args["mem_size"], + qemu_default_args["num_cores"], + qemu_default_args["num_threads"], + connection_settings_default["port"], + ) + + if qemu_default_args["initrd_image"]: + if not os.path.exists(qemu_default_args["initrd_image"]): + raise FileNotFoundError(f'{qemu_default_args["initrd_image"]} does not exist!') + + qemu_cmd += f' -initrd {qemu_default_args["initrd_image"]}' + + if qemu_default_args["arch"] == machine(): + if qemu_default_args["enable_kvm"]: + qemu_cmd += ' --enable-kvm' + else: + qemu_cmd += f' -machine virt -cpu {qemu_default_args["cpu_type"]}' + + self.target = make_target(connect=False, + conn_cls=SshConnection, + connection_settings=connection_settings_default) + + super().__init__(runner_cmd=qemu_cmd, + target=self.target, + **args) + + def _get_model_name(section): name_string = section['model name'] parts = name_string.split('@')[0].strip().split() diff --git a/tests/target_configs.yaml b/tests/target_configs.yaml index b4a3a16ad..034294198 100644 --- a/tests/target_configs.yaml +++ b/tests/target_configs.yaml @@ -19,3 +19,13 @@ LocalLinuxTarget: connection_settings: unrooted: True +QEMUTargetRunner: + entry-0: + qemu_params: + kernel_image: '/home/username/devlib/buildroot/output/images/Image' + + entry-1: + qemu_params: + kernel_image: '/home/username/devlib/buildroot/output/images/bzImage' + arch: 'x86_64' + cmdline: 'console=ttyS0' diff --git a/tests/test_target.py b/tests/test_target.py index 784c099e0..e15506f43 100644 --- a/tests/test_target.py +++ b/tests/test_target.py @@ -19,7 +19,7 @@ from pprint import pp from unittest import TestCase -from devlib import AndroidTarget, LinuxTarget, LocalLinuxTarget +from devlib import AndroidTarget, LinuxTarget, LocalLinuxTarget, QEMUTargetRunner from devlib.utils.android import AdbConnection from devlib.utils.misc import load_struct_from_yaml @@ -90,3 +90,13 @@ def run_test(target): target = LocalLinuxTarget(connection_settings=entry['connection_settings']) run_test(target) + if target_configs.get('QEMUTargetRunner') is not None: + print('> QEMU target runners:') + for entry in target_configs['QEMUTargetRunner'].values(): + pp(entry) + with QEMUTargetRunner( + qemu_params=entry['qemu_params'], + connection_settings=entry['connection_settings'], + ) as qemu_target: + run_test(qemu_target.target) +