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 28, 2024
1 parent ecf6680 commit 8fbb652
Show file tree
Hide file tree
Showing 5 changed files with 316 additions and 7 deletions.
2 changes: 2 additions & 0 deletions devlib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
ChromeOsTarget,
)

from devlib.target_runner import QEMUTargetRunner

from devlib.host import (
PACKAGE_BIN_DIRECTORY,
LocalConnection,
Expand Down
2 changes: 1 addition & 1 deletion devlib/target.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,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
273 changes: 273 additions & 0 deletions devlib/target_runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
# Copyright 2024 ARM Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

"""
Target runner and related classes are implemented here.
"""

import logging
import os
import signal
import subprocess
import time
from platform import machine

from devlib.exception import (TargetStableError, HostError)
from devlib.target import LinuxTarget
from devlib.utils.misc import get_subprocess, which
from devlib.utils.ssh import SshConnection


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`).
:param runner_cmd: The command to start runner process (e.g.,
``qemu-system-aarch64 -kernel Image -append "console=ttyAMA0" ...``).
:type runner_cmd: str
:param target: Specifies type of target per :class:`Target` based classes.
:type target: Target
:param connect: Specifies if :class:`TargetRunner` should try to connect
target after launching it, defaults to True.
:type connect: bool, optional
:param boot_timeout: Timeout for target's being ready for SSH access in
seconds, defaults to 60.
:type boot_timeout: int, optional
:raises HostError: if it cannot execute runner command successfully.
:raises TargetStableError: if Target is inaccessible.
"""

def __init__(self,
runner_cmd,
target,
connect=True,
boot_timeout=60):
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.
:return: Self object.
:rtype: TargetRunner
"""

return self

def __exit__(self, *_):
"""
Exit routine for contextmanager.
Ensure :attr:`TargetRunner.runner_process` is terminated on exit.
"""

self.terminate()

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()
raise TargetStableError(f'Target is inaccessible for {self.boot_timeout} seconds!')

def terminate(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.
:param qemu_settings: 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.
:type qemu_settings: Dict
:param connection_settings: the dictionary to store connection settings
of :attr:`Target.connection_settings`, defaults to None.
:type connection_settings: Dict, optional
:param make_target: Lambda function for creating :class:`Target` based
object, defaults to :func:`lambda **kwargs: LinuxTarget(**kwargs)`.
:type make_target: func, optional
:Variable positional arguments: Forwarded to :class:`TargetRunner`.
:raises FileNotFoundError: if QEMU executable, kernel or initrd image cannot be found.
"""

def __init__(self,
qemu_settings,
connection_settings=None,
# pylint: disable=unnecessary-lambda
make_target=lambda **kwargs: LinuxTarget(**kwargs),
**args):
self.connection_settings = {
'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:
self.connection_settings = {**self.connection_settings, **connection_settings}

qemu_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 QEMU arguments with :param:`qemu_settings`.
qemu_args.update(
(key, value)
for key, value in qemu_settings.items()
if key in qemu_args
)

qemu_executable = f'qemu-system-{qemu_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_args["kernel_image"]):
raise FileNotFoundError(f'{qemu_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_args["kernel_image"],
qemu_args["cmdline"],
qemu_args["mem_size"],
qemu_args["num_cores"],
qemu_args["num_threads"],
self.connection_settings["port"],
)

if qemu_args["initrd_image"]:
if not os.path.exists(qemu_args["initrd_image"]):
raise FileNotFoundError(f'{qemu_args["initrd_image"]} does not exist!')

qemu_cmd += f' -initrd {qemu_args["initrd_image"]}'

if qemu_args["arch"] == machine():
if qemu_args["enable_kvm"]:
qemu_cmd += ' --enable-kvm'
else:
qemu_cmd += f' -machine virt -cpu {qemu_args["cpu_type"]}'

self.target = make_target(connect=False,
conn_cls=SshConnection,
connection_settings=self.connection_settings)

super().__init__(runner_cmd=qemu_cmd,
target=self.target,
**args)
13 changes: 13 additions & 0 deletions tests/target_configs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,16 @@ LocalLinuxTarget:
connection_settings:
unrooted: True

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

entry-1:
connection_settings:
port : 8023

qemu_settings:
kernel_image: '/path/to/devlib/tools/buildroot/buildroot-v2023.11.1-x86_64/output/images/bzImage'
arch: 'x86_64'
cmdline: 'console=ttyS0'
33 changes: 27 additions & 6 deletions tests/test_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from pprint import pp
import pytest

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 All @@ -44,32 +44,49 @@ def build_targets():
connection_settings=entry['connection_settings'],
conn_cls=lambda **kwargs: AdbConnection(adb_as_root=True, **kwargs),
)
targets.append(a_target)
targets.append((a_target, None))

if target_configs.get('LinuxTarget') is not None:
print('> Linux targets:')
for entry in target_configs['LinuxTarget'].values():
pp(entry)
l_target = LinuxTarget(connection_settings=entry['connection_settings'])
targets.append(l_target)
targets.append((l_target, None))

if target_configs.get('LocalLinuxTarget') is not None:
print('> LocalLinux targets:')
for entry in target_configs['LocalLinuxTarget'].values():
pp(entry)
ll_target = LocalLinuxTarget(connection_settings=entry['connection_settings'])
targets.append(ll_target)
targets.append((ll_target, None))

if target_configs.get('QEMUTargetRunner') is not None:
print('> QEMU target runners:')
for entry in target_configs['QEMUTargetRunner'].values():
pp(entry)
qemu_settings = entry.get('qemu_settings') and entry['qemu_settings']
connection_settings = entry.get(
'connection_settings') and entry['connection_settings']

qemu_runner = QEMUTargetRunner(
qemu_settings=qemu_settings,
connection_settings=connection_settings,
)
targets.append((qemu_runner.target, qemu_runner))

return targets


@pytest.mark.parametrize("target", build_targets())
def test_read_multiline_values(target):
@pytest.mark.parametrize("target, target_runner", build_targets())
def test_read_multiline_values(target, target_runner):
"""
Test Target.read_tree_values_flat()
:param target: Type of target per :class:`Target` based classes.
:type target: Target
:param target_runner: Target runner object to terminate target (if necessary).
:type target: TargetRunner
"""

data = {
Expand All @@ -96,4 +113,8 @@ def test_read_multiline_values(target):
print(f'Removing {target.working_directory}...')
target.remove(target.working_directory)

if target_runner is not None:
print('Terminating target runner...')
target_runner.terminate()

assert {k: v.strip() for k, v in data.items()} == result

0 comments on commit 8fbb652

Please sign in to comment.