Skip to content

Commit

Permalink
target: Implement target runner classes
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
metin-arm committed Feb 8, 2024
1 parent 53983bf commit 0504f1c
Show file tree
Hide file tree
Showing 4 changed files with 270 additions and 4 deletions.
2 changes: 1 addition & 1 deletion devlib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

from devlib.target import (
Target, LinuxTarget, AndroidTarget, LocalLinuxTarget,
ChromeOsTarget,
ChromeOsTarget, QEMUTargetRunner,
)

from devlib.host import (
Expand Down
248 changes: 246 additions & 2 deletions devlib/target.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import time
import logging
import posixpath
import signal
import subprocess
import tarfile
import tempfile
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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)))
Expand Down Expand Up @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions tests/target_configs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,13 @@ LocalLinuxTarget:
connection_settings:
unrooted: True

QEMUTargetRunner:
entry-0:
qemu_params:
kernel_image: '/devlib/tools/buildroot/buildroot-v2023.11.1-aarch64/output/images/Image'

entry-1:
qemu_params:
kernel_image: '/devlib/tools/buildroot/buildroot-v2023.11.1-x86_64/output/images/bzImage'
arch: 'x86_64'
cmdline: 'console=ttyS0'
14 changes: 13 additions & 1 deletion tests/test_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -90,3 +90,15 @@ 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)
qemu_params = entry.get('qemu_params') and entry['qemu_params']
connection_settings = entry.get('connection_settings') and entry['connection_settings']
with QEMUTargetRunner(
qemu_params=qemu_params,
connection_settings=connection_settings,
) as qemu_target:
run_test(qemu_target.target)

0 comments on commit 0504f1c

Please sign in to comment.