Skip to content

Commit 2f92299

Browse files
committed
tests: microvm: refactor Jailer class
- make the jailer base class generic - move `screen` bits to a special purpose "jailer" Abstract the idea of the jailer into a Runner. The idea is that we can create different runner modes for different cases. For example we in this commit we have the jailer and screen+jailer, but we could extend it to others, like not using the Firecracker jailer at all. Signed-off-by: Pablo Barbáchano <[email protected]>
1 parent 0d2713b commit 2f92299

17 files changed

+328
-276
lines changed

tests/conftest.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -341,14 +341,12 @@ def microvm_factory(request, record_property, results_dir, netns_factory):
341341
uvm_data.mkdir()
342342
uvm_data.joinpath("host-dmesg.log").write_text(dmesg.stdout)
343343

344-
uvm_root = Path(uvm.chroot())
345-
for item in os.listdir(uvm_root):
346-
src = uvm_root / item
347-
if not os.path.isfile(src):
344+
for src in uvm.chroot.iterdir():
345+
if not src.is_file():
348346
continue
349-
dst = uvm_data / item
347+
dst = uvm_data / src.name
350348
shutil.copy(src, dst)
351-
console_data = uvm.console_data
349+
console_data = getattr(uvm.jailer, "console_data", None)
352350
if console_data:
353351
uvm_data.joinpath("guest-console.log").write_text(console_data)
354352

tests/framework/jailer.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import os
66
import shutil
7+
import signal
78
import stat
89
from pathlib import Path
910

@@ -42,6 +43,7 @@ class JailerContext:
4243
def __init__(
4344
self,
4445
jailer_id,
46+
jailer_binary_path,
4547
exec_file,
4648
uid=1234,
4749
gid=1234,
@@ -63,6 +65,7 @@ def __init__(
6365
"""
6466
self.jailer_id = jailer_id
6567
assert jailer_id is not None
68+
self.jailer_bin_path = jailer_binary_path
6669
self.exec_file = exec_file
6770
self.uid = uid
6871
self.gid = gid
@@ -89,7 +92,7 @@ def construct_param_list(self):
8992
might want to add integration tests that validate the enforcement of
9093
mandatory arguments.
9194
"""
92-
jailer_param_list = []
95+
jailer_param_list = [str(self.jailer_bin_path)]
9396

9497
# Pretty please, try to keep the same order as in the code base.
9598
if self.jailer_id is not None:
@@ -141,7 +144,7 @@ def api_socket_path(self):
141144

142145
def chroot_path(self):
143146
"""Return the MicroVM chroot path."""
144-
return os.path.join(self.chroot_base_with_id(), "root")
147+
return self.chroot_base_with_id() / "root"
145148

146149
def jailed_path(self, file_path, create=False, subdir="."):
147150
"""Create a hard link or block special device owned by uid:gid.
@@ -176,6 +179,8 @@ def jailed_path(self, file_path, create=False, subdir="."):
176179
def setup(self):
177180
"""Set up this jailer context."""
178181
os.makedirs(self.chroot_base, exist_ok=True)
182+
# Copy the /etc/localtime file in the jailer root
183+
self.jailed_path("/etc/localtime", subdir="etc")
179184

180185
def cleanup(self):
181186
"""Clean up this jailer context."""
@@ -243,6 +248,23 @@ def _kill_cgroup_tasks(self, controller):
243248
return True
244249

245250
@property
246-
def pid_file(self):
247-
"""Return the PID file of the jailed process"""
248-
return Path(self.chroot_path()) / (self.exec_file.name + ".pid")
251+
def pid(self):
252+
"""Return the PID of the jailed process"""
253+
# Read the PID stored inside the file.
254+
pid_file = Path(self.chroot_path()) / (self.exec_file.name + ".pid")
255+
if not pid_file.exists():
256+
return None
257+
return int(pid_file.read_text(encoding="ascii"))
258+
259+
def spawn(self, pre_cmd):
260+
"""Spawn Firecracker and daemonize via the Jailer"""
261+
cmd = pre_cmd or []
262+
cmd += self.construct_param_list()
263+
if not self.daemonize:
264+
raise RuntimeError("Use a different jailer")
265+
return utils.check_output(cmd, shell=False)
266+
267+
def kill(self):
268+
"""Kill the Firecracker process"""
269+
if self.pid is not None:
270+
os.kill(self.pid, signal.SIGKILL)

tests/framework/jailer_screen.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
# Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Run the jailer under a screen session"""
5+
6+
import os
7+
import re
8+
import select
9+
import signal
10+
import time
11+
from pathlib import Path
12+
13+
import psutil
14+
from tenacity import Retrying, retry_if_exception_type, stop_after_attempt, wait_fixed
15+
16+
from framework import utils
17+
18+
from .jailer import JailerContext
19+
20+
FLUSH_CMD = 'screen -S {session} -X colon "logfile flush 0^M"'
21+
22+
23+
def start_screen_process(screen_log, session_name, binary_path, binary_params):
24+
"""Start binary process into a screen session."""
25+
start_cmd = "screen -L -Logfile {logfile} -dmS {session} {binary} {params}"
26+
start_cmd = start_cmd.format(
27+
logfile=screen_log,
28+
session=session_name,
29+
binary=binary_path,
30+
params=" ".join(binary_params),
31+
)
32+
33+
utils.check_output(start_cmd)
34+
35+
# Build a regex object to match (number).session_name
36+
regex_object = re.compile(r"([0-9]+)\.{}".format(session_name))
37+
38+
# Run 'screen -ls' in a retry loop, 30 times with a 1s delay between calls.
39+
# If the output of 'screen -ls' matches the regex object, it will return the
40+
# PID. Otherwise, a RuntimeError will be raised.
41+
for attempt in Retrying(
42+
retry=retry_if_exception_type(RuntimeError),
43+
stop=stop_after_attempt(30),
44+
wait=wait_fixed(1),
45+
reraise=True,
46+
):
47+
with attempt:
48+
screen_pid = utils.search_output_from_cmd(
49+
cmd="screen -ls", find_regex=regex_object
50+
).group(1)
51+
52+
screen_pid = int(screen_pid)
53+
# Make sure the screen process launched successfully
54+
# As the parent process for the binary.
55+
screen_ps = psutil.Process(screen_pid)
56+
57+
for attempt in Retrying(
58+
stop=stop_after_attempt(5),
59+
wait=wait_fixed(0.5),
60+
reraise=True,
61+
):
62+
with attempt:
63+
assert screen_ps.is_running()
64+
65+
# Configure screen to flush stdout to file.
66+
utils.check_output(FLUSH_CMD.format(session=session_name))
67+
68+
return screen_pid
69+
70+
71+
class JailerScreen(JailerContext):
72+
"""Spawn Firecracker under screen"""
73+
74+
def __init__(self, *args, **kwargs):
75+
super().__init__(*args, **kwargs)
76+
self.daemonize = False
77+
self.screen_pid = None
78+
self.expect_kill_by_signal = False
79+
80+
def spawn(self, pre_cmd):
81+
"""Spawn Firecracker under screen"""
82+
self.screen_pid = None
83+
cmd = pre_cmd or []
84+
cmd += self.construct_param_list()
85+
# Run Firecracker under screen. This is used when we want to access
86+
# the serial console. The file will collect the output from
87+
# 'screen'ed Firecracker.
88+
self.screen_pid = start_screen_process(
89+
self.screen_log,
90+
self.screen_session,
91+
cmd[0],
92+
cmd[1:],
93+
)
94+
95+
# If `--new-pid-ns` is used, the Firecracker process will detach from
96+
# the screen and the screen process will exit. We do not want to
97+
# attempt to kill it in that case to avoid a race condition.
98+
if self.new_pid_ns:
99+
self.screen_pid = None
100+
101+
def kill(self):
102+
"""Kill the Firecracker process"""
103+
if not self.screen_pid:
104+
raise RuntimeError("screen process not started")
105+
# Killing screen will send SIGHUP to underlying Firecracker.
106+
# Needed to avoid false positives in case kill() is called again.
107+
self.expect_kill_by_signal = True
108+
os.kill(self.screen_pid, signal.SIGKILL)
109+
os.kill(self.pid, signal.SIGKILL)
110+
111+
@property
112+
def console_data(self):
113+
"""Return the output of microVM's console"""
114+
if self.screen_log is None:
115+
return None
116+
file = Path(self.screen_log)
117+
if not file.exists():
118+
return None
119+
return file.read_text(encoding="utf-8")
120+
121+
@property
122+
def screen_session(self):
123+
"""The screen session name
124+
125+
The id of this microVM, which should be unique.
126+
"""
127+
return self.jailer_id
128+
129+
@property
130+
def screen_log(self):
131+
"""Get the screen log file."""
132+
return f"/tmp/screen-{self.screen_session}.log"
133+
134+
def serial_input(self, input_string):
135+
"""Send a string to the Firecracker serial console via screen."""
136+
input_cmd = f'screen -S {self.screen_session} -p 0 -X stuff "{input_string}"'
137+
return utils.check_output(input_cmd)
138+
139+
def serial(self):
140+
"""Get a Serial object for this jailer/microvm"""
141+
return Serial(self)
142+
143+
144+
class Serial:
145+
"""Class for serial console communication with a Microvm."""
146+
147+
RX_TIMEOUT_S = 60
148+
149+
def __init__(self, screen_jailer):
150+
"""Initialize a new Serial object."""
151+
self._poller = None
152+
self._screen_jailer = screen_jailer
153+
154+
def open(self):
155+
"""Open a serial connection."""
156+
# Open the screen log file.
157+
if self._poller is not None:
158+
# serial already opened
159+
return
160+
161+
attempt = 0
162+
while not Path(self._screen_jailer.screen_log).exists() and attempt < 5:
163+
time.sleep(0.2)
164+
attempt += 1
165+
166+
screen_log_fd = os.open(self._screen_jailer.screen_log, os.O_RDONLY)
167+
self._poller = select.poll()
168+
self._poller.register(screen_log_fd, select.POLLIN | select.POLLHUP)
169+
170+
def tx(self, input_string, end="\n"):
171+
# pylint: disable=invalid-name
172+
# No need to have a snake_case naming style for a single word.
173+
r"""Send a string terminated by an end token (defaulting to "\n")."""
174+
self._screen_jailer.serial_input(input_string + end)
175+
176+
def rx_char(self):
177+
"""Read a single character."""
178+
result = self._poller.poll(0.1)
179+
180+
for fd, flag in result:
181+
if flag & select.POLLHUP:
182+
assert False, "Oh! The console vanished before test completed."
183+
184+
if flag & select.POLLIN:
185+
output_char = str(os.read(fd, 1), encoding="utf-8", errors="ignore")
186+
return output_char
187+
188+
return ""
189+
190+
def rx(self, token="\n"):
191+
# pylint: disable=invalid-name
192+
# No need to have a snake_case naming style for a single word.
193+
r"""Read a string delimited by an end token (defaults to "\n")."""
194+
rx_str = ""
195+
start = time.time()
196+
while True:
197+
rx_str += self.rx_char()
198+
if rx_str.endswith(token):
199+
break
200+
if (time.time() - start) >= self.RX_TIMEOUT_S:
201+
self._screen_jailer.kill()
202+
assert False
203+
204+
return rx_str

0 commit comments

Comments
 (0)