From 84050ed4017d8254facc5a570b4e6ed2c71da193 Mon Sep 17 00:00:00 2001
From: Metin Kaya <metin.kaya@arm.com>
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 <metin.kaya@arm.com>
---
 devlib/__init__.py        |   2 +-
 devlib/target.py          | 248 +++++++++++++++++++++++++++++++++++++-
 tests/target_configs.yaml |  10 ++
 tests/test_target.py      |  16 ++-
 4 files changed, 272 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..95e27054d 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: '/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'
diff --git a/tests/test_target.py b/tests/test_target.py
index 784c099e0..86a242071 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,17 @@ 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)
+