Skip to content

Commit 8fbb652

Browse files
committed
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>
1 parent ecf6680 commit 8fbb652

File tree

5 files changed

+316
-7
lines changed

5 files changed

+316
-7
lines changed

devlib/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
ChromeOsTarget,
2323
)
2424

25+
from devlib.target_runner import QEMUTargetRunner
26+
2527
from devlib.host import (
2628
PACKAGE_BIN_DIRECTORY,
2729
LocalConnection,

devlib/target.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,7 @@ async def setup(self, executables=None):
494494
# Check for platform dependent setup procedures
495495
self.platform.setup(self)
496496

497-
# Initialize modules which requires Buxybox (e.g. shutil dependent tasks)
497+
# Initialize modules which requires Busybox (e.g. shutil dependent tasks)
498498
self._update_modules('setup')
499499

500500
await self.execute.asyn('mkdir -p {}'.format(quote(self._file_transfer_cache)))

devlib/target_runner.py

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
# Copyright 2024 ARM Limited
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
16+
"""
17+
Target runner and related classes are implemented here.
18+
"""
19+
20+
import logging
21+
import os
22+
import signal
23+
import subprocess
24+
import time
25+
from platform import machine
26+
27+
from devlib.exception import (TargetStableError, HostError)
28+
from devlib.target import LinuxTarget
29+
from devlib.utils.misc import get_subprocess, which
30+
from devlib.utils.ssh import SshConnection
31+
32+
33+
class TargetRunner:
34+
"""
35+
A generic class for interacting with targets runners.
36+
37+
It mainly aims to provide framework support for QEMU like target runners
38+
(e.g., :class:`QEMUTargetRunner`).
39+
40+
:param runner_cmd: The command to start runner process (e.g.,
41+
``qemu-system-aarch64 -kernel Image -append "console=ttyAMA0" ...``).
42+
:type runner_cmd: str
43+
44+
:param target: Specifies type of target per :class:`Target` based classes.
45+
:type target: Target
46+
47+
:param connect: Specifies if :class:`TargetRunner` should try to connect
48+
target after launching it, defaults to True.
49+
:type connect: bool, optional
50+
51+
:param boot_timeout: Timeout for target's being ready for SSH access in
52+
seconds, defaults to 60.
53+
:type boot_timeout: int, optional
54+
55+
:raises HostError: if it cannot execute runner command successfully.
56+
57+
:raises TargetStableError: if Target is inaccessible.
58+
"""
59+
60+
def __init__(self,
61+
runner_cmd,
62+
target,
63+
connect=True,
64+
boot_timeout=60):
65+
self.boot_timeout = boot_timeout
66+
self.target = target
67+
68+
self.logger = logging.getLogger(self.__class__.__name__)
69+
70+
self.logger.info('runner_cmd: %s', runner_cmd)
71+
72+
try:
73+
self.runner_process = get_subprocess(list(runner_cmd.split()))
74+
except Exception as ex:
75+
raise HostError(f'Error while running "{runner_cmd}": {ex}') from ex
76+
77+
if connect:
78+
self.wait_boot_complete()
79+
80+
def __enter__(self):
81+
"""
82+
Complementary method for contextmanager.
83+
84+
:return: Self object.
85+
:rtype: TargetRunner
86+
"""
87+
88+
return self
89+
90+
def __exit__(self, *_):
91+
"""
92+
Exit routine for contextmanager.
93+
94+
Ensure :attr:`TargetRunner.runner_process` is terminated on exit.
95+
"""
96+
97+
self.terminate()
98+
99+
def wait_boot_complete(self):
100+
"""
101+
Wait for target OS to finish boot up and become accessible over SSH in at most
102+
:attr:`TargetRunner.boot_timeout` seconds.
103+
104+
:raises TargetStableError: In case of timeout.
105+
"""
106+
107+
start_time = time.time()
108+
elapsed = 0
109+
while self.boot_timeout >= elapsed:
110+
try:
111+
self.target.connect(timeout=self.boot_timeout - elapsed)
112+
self.logger.info('Target is ready.')
113+
return
114+
# pylint: disable=broad-except
115+
except BaseException as ex:
116+
self.logger.info('Cannot connect target: %s', ex)
117+
118+
time.sleep(1)
119+
elapsed = time.time() - start_time
120+
121+
self.terminate()
122+
raise TargetStableError(f'Target is inaccessible for {self.boot_timeout} seconds!')
123+
124+
def terminate(self):
125+
"""
126+
Terminate :attr:`TargetRunner.runner_process`.
127+
"""
128+
129+
if self.runner_process is None:
130+
return
131+
132+
try:
133+
self.runner_process.stdin.close()
134+
self.runner_process.stdout.close()
135+
self.runner_process.stderr.close()
136+
137+
if self.runner_process.poll() is None:
138+
self.logger.debug('Terminating target runner...')
139+
os.killpg(self.runner_process.pid, signal.SIGTERM)
140+
# Wait 3 seconds before killing the runner.
141+
self.runner_process.wait(timeout=3)
142+
except subprocess.TimeoutExpired:
143+
self.logger.info('Killing target runner...')
144+
os.killpg(self.runner_process.pid, signal.SIGKILL)
145+
146+
147+
class QEMUTargetRunner(TargetRunner):
148+
"""
149+
Class for interacting with QEMU runners.
150+
151+
:class:`QEMUTargetRunner` is a subclass of :class:`TargetRunner` which performs necessary
152+
groundwork for launching a guest OS on QEMU.
153+
154+
:param qemu_settings: A dictionary which has QEMU related parameters. The full list
155+
of QEMU parameters is below:
156+
* ``kernel_image``: This is the location of kernel image (e.g., ``Image``) which
157+
will be used as target's kernel.
158+
159+
* ``arch``: Architecture type. Defaults to ``aarch64``.
160+
161+
* ``cpu_types``: List of CPU ids for QEMU. The list only contains ``cortex-a72`` by
162+
default. This parameter is valid for Arm architectures only.
163+
164+
* ``initrd_image``: This points to the location of initrd image (e.g.,
165+
``rootfs.cpio.xz``) which will be used as target's root filesystem if kernel
166+
does not include one already.
167+
168+
* ``mem_size``: Size of guest memory in MiB.
169+
170+
* ``num_cores``: Number of CPU cores. Guest will have ``2`` cores by default.
171+
172+
* ``num_threads``: Number of CPU threads. Set to ``2`` by defaults.
173+
174+
* ``cmdline``: Kernel command line parameter. It only specifies console device in
175+
default (i.e., ``console=ttyAMA0``) which is valid for Arm architectures.
176+
May be changed to ``ttyS0`` for x86 platforms.
177+
178+
* ``enable_kvm``: Specifies if KVM will be used as accelerator in QEMU or not.
179+
Enabled by default if host architecture matches with target's for improving
180+
QEMU performance.
181+
:type qemu_settings: Dict
182+
183+
:param connection_settings: the dictionary to store connection settings
184+
of :attr:`Target.connection_settings`, defaults to None.
185+
:type connection_settings: Dict, optional
186+
187+
:param make_target: Lambda function for creating :class:`Target` based
188+
object, defaults to :func:`lambda **kwargs: LinuxTarget(**kwargs)`.
189+
:type make_target: func, optional
190+
191+
:Variable positional arguments: Forwarded to :class:`TargetRunner`.
192+
193+
:raises FileNotFoundError: if QEMU executable, kernel or initrd image cannot be found.
194+
"""
195+
196+
def __init__(self,
197+
qemu_settings,
198+
connection_settings=None,
199+
# pylint: disable=unnecessary-lambda
200+
make_target=lambda **kwargs: LinuxTarget(**kwargs),
201+
**args):
202+
self.connection_settings = {
203+
'host': '127.0.0.1',
204+
'port': 8022,
205+
'username': 'root',
206+
'password': 'root',
207+
'strict_host_check': False,
208+
}
209+
210+
# Update default connection settings with :param:`connection_settings` (if exists).
211+
if connection_settings is not None:
212+
self.connection_settings = {**self.connection_settings, **connection_settings}
213+
214+
qemu_args = {
215+
'kernel_image': '',
216+
'arch': 'aarch64',
217+
'cpu_type': 'cortex-a72',
218+
'initrd_image': '',
219+
'mem_size': 512,
220+
'num_cores': 2,
221+
'num_threads': 2,
222+
'cmdline': 'console=ttyAMA0',
223+
'enable_kvm': True,
224+
}
225+
226+
# Update QEMU arguments with :param:`qemu_settings`.
227+
qemu_args.update(
228+
(key, value)
229+
for key, value in qemu_settings.items()
230+
if key in qemu_args
231+
)
232+
233+
qemu_executable = f'qemu-system-{qemu_args["arch"]}'
234+
qemu_path = which(qemu_executable)
235+
if qemu_path is None:
236+
raise FileNotFoundError(f'Cannot find {qemu_executable} executable!')
237+
238+
if not os.path.exists(qemu_args["kernel_image"]):
239+
raise FileNotFoundError(f'{qemu_args["kernel_image"]} does not exist!')
240+
241+
# pylint: disable=consider-using-f-string
242+
qemu_cmd = '''\
243+
{} -kernel {} -append "{}" -m {} -smp cores={},threads={} -netdev user,id=net0,hostfwd=tcp::{}-:22 \
244+
-device virtio-net-pci,netdev=net0 --nographic\
245+
'''.format(
246+
qemu_path,
247+
qemu_args["kernel_image"],
248+
qemu_args["cmdline"],
249+
qemu_args["mem_size"],
250+
qemu_args["num_cores"],
251+
qemu_args["num_threads"],
252+
self.connection_settings["port"],
253+
)
254+
255+
if qemu_args["initrd_image"]:
256+
if not os.path.exists(qemu_args["initrd_image"]):
257+
raise FileNotFoundError(f'{qemu_args["initrd_image"]} does not exist!')
258+
259+
qemu_cmd += f' -initrd {qemu_args["initrd_image"]}'
260+
261+
if qemu_args["arch"] == machine():
262+
if qemu_args["enable_kvm"]:
263+
qemu_cmd += ' --enable-kvm'
264+
else:
265+
qemu_cmd += f' -machine virt -cpu {qemu_args["cpu_type"]}'
266+
267+
self.target = make_target(connect=False,
268+
conn_cls=SshConnection,
269+
connection_settings=self.connection_settings)
270+
271+
super().__init__(runner_cmd=qemu_cmd,
272+
target=self.target,
273+
**args)

tests/target_configs.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,16 @@ LocalLinuxTarget:
1515
connection_settings:
1616
unrooted: True
1717

18+
QEMUTargetRunner:
19+
entry-0:
20+
qemu_settings:
21+
kernel_image: '/path/to/devlib/tools/buildroot/buildroot-v2023.11.1-aarch64/output/images/Image'
22+
23+
entry-1:
24+
connection_settings:
25+
port : 8023
26+
27+
qemu_settings:
28+
kernel_image: '/path/to/devlib/tools/buildroot/buildroot-v2023.11.1-x86_64/output/images/bzImage'
29+
arch: 'x86_64'
30+
cmdline: 'console=ttyS0'

tests/test_target.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from pprint import pp
2121
import pytest
2222

23-
from devlib import AndroidTarget, LinuxTarget, LocalLinuxTarget
23+
from devlib import AndroidTarget, LinuxTarget, LocalLinuxTarget, QEMUTargetRunner
2424
from devlib.utils.android import AdbConnection
2525
from devlib.utils.misc import load_struct_from_yaml
2626

@@ -44,32 +44,49 @@ def build_targets():
4444
connection_settings=entry['connection_settings'],
4545
conn_cls=lambda **kwargs: AdbConnection(adb_as_root=True, **kwargs),
4646
)
47-
targets.append(a_target)
47+
targets.append((a_target, None))
4848

4949
if target_configs.get('LinuxTarget') is not None:
5050
print('> Linux targets:')
5151
for entry in target_configs['LinuxTarget'].values():
5252
pp(entry)
5353
l_target = LinuxTarget(connection_settings=entry['connection_settings'])
54-
targets.append(l_target)
54+
targets.append((l_target, None))
5555

5656
if target_configs.get('LocalLinuxTarget') is not None:
5757
print('> LocalLinux targets:')
5858
for entry in target_configs['LocalLinuxTarget'].values():
5959
pp(entry)
6060
ll_target = LocalLinuxTarget(connection_settings=entry['connection_settings'])
61-
targets.append(ll_target)
61+
targets.append((ll_target, None))
62+
63+
if target_configs.get('QEMUTargetRunner') is not None:
64+
print('> QEMU target runners:')
65+
for entry in target_configs['QEMUTargetRunner'].values():
66+
pp(entry)
67+
qemu_settings = entry.get('qemu_settings') and entry['qemu_settings']
68+
connection_settings = entry.get(
69+
'connection_settings') and entry['connection_settings']
70+
71+
qemu_runner = QEMUTargetRunner(
72+
qemu_settings=qemu_settings,
73+
connection_settings=connection_settings,
74+
)
75+
targets.append((qemu_runner.target, qemu_runner))
6276

6377
return targets
6478

6579

66-
@pytest.mark.parametrize("target", build_targets())
67-
def test_read_multiline_values(target):
80+
@pytest.mark.parametrize("target, target_runner", build_targets())
81+
def test_read_multiline_values(target, target_runner):
6882
"""
6983
Test Target.read_tree_values_flat()
7084
7185
:param target: Type of target per :class:`Target` based classes.
7286
:type target: Target
87+
88+
:param target_runner: Target runner object to terminate target (if necessary).
89+
:type target: TargetRunner
7390
"""
7491

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

116+
if target_runner is not None:
117+
print('Terminating target runner...')
118+
target_runner.terminate()
119+
99120
assert {k: v.strip() for k, v in data.items()} == result

0 commit comments

Comments
 (0)