summaryrefslogtreecommitdiffstats
path: root/meta/lib/oeqa/core
diff options
context:
space:
mode:
Diffstat (limited to 'meta/lib/oeqa/core')
-rw-r--r--meta/lib/oeqa/core/case.py8
-rw-r--r--meta/lib/oeqa/core/decorator/data.py12
-rw-r--r--meta/lib/oeqa/core/runner.py2
-rw-r--r--meta/lib/oeqa/core/target/serial.py315
-rw-r--r--meta/lib/oeqa/core/target/ssh.py56
-rw-r--r--meta/lib/oeqa/core/tests/common.py1
6 files changed, 374 insertions, 20 deletions
diff --git a/meta/lib/oeqa/core/case.py b/meta/lib/oeqa/core/case.py
index bc4446a938..ad5524a714 100644
--- a/meta/lib/oeqa/core/case.py
+++ b/meta/lib/oeqa/core/case.py
@@ -5,6 +5,7 @@
5# 5#
6 6
7import base64 7import base64
8import os
8import zlib 9import zlib
9import unittest 10import unittest
10 11
@@ -57,6 +58,13 @@ class OETestCase(unittest.TestCase):
57 d.tearDownDecorator() 58 d.tearDownDecorator()
58 self.tearDownMethod() 59 self.tearDownMethod()
59 60
61 def assertFileExists(self, filename, msg=None):
62 """
63 Test that filename exists. If it does not, the test will fail.
64 """
65 if not os.path.exists(filename):
66 self.fail(msg or "%s does not exist" % filename)
67
60class OEPTestResultTestCase: 68class OEPTestResultTestCase:
61 """ 69 """
62 Mix-in class to provide functions to make interacting with extraresults for 70 Mix-in class to provide functions to make interacting with extraresults for
diff --git a/meta/lib/oeqa/core/decorator/data.py b/meta/lib/oeqa/core/decorator/data.py
index 5444b2cb75..0daf46334f 100644
--- a/meta/lib/oeqa/core/decorator/data.py
+++ b/meta/lib/oeqa/core/decorator/data.py
@@ -228,3 +228,15 @@ class skipIfNotArch(OETestDecorator):
228 arch = self.case.td['HOST_ARCH'] 228 arch = self.case.td['HOST_ARCH']
229 if arch not in self.archs: 229 if arch not in self.archs:
230 self.case.skipTest('Test skipped on %s' % arch) 230 self.case.skipTest('Test skipped on %s' % arch)
231
232@registerDecorator
233class skipIfNotBuildArch(OETestDecorator):
234 """
235 Skip test if BUILD_ARCH is not present in the tuple specified.
236 """
237
238 attrs = ('archs',)
239 def setUpDecorator(self):
240 arch = self.case.td['BUILD_ARCH']
241 if arch not in self.archs:
242 self.case.skipTest('Test skipped on %s' % arch)
diff --git a/meta/lib/oeqa/core/runner.py b/meta/lib/oeqa/core/runner.py
index a86a706bd9..b683d9b80a 100644
--- a/meta/lib/oeqa/core/runner.py
+++ b/meta/lib/oeqa/core/runner.py
@@ -357,7 +357,7 @@ class OETestResultJSONHelper(object):
357 os.makedirs(write_dir, exist_ok=True) 357 os.makedirs(write_dir, exist_ok=True)
358 test_results = self._get_existing_testresults_if_available(write_dir) 358 test_results = self._get_existing_testresults_if_available(write_dir)
359 test_results[result_id] = {'configuration': configuration, 'result': test_result} 359 test_results[result_id] = {'configuration': configuration, 'result': test_result}
360 json_testresults = json.dumps(test_results, sort_keys=True, indent=4) 360 json_testresults = json.dumps(test_results, sort_keys=True, indent=1)
361 self._write_file(write_dir, self.testresult_filename, json_testresults) 361 self._write_file(write_dir, self.testresult_filename, json_testresults)
362 if has_bb: 362 if has_bb:
363 bb.utils.unlockfile(lf) 363 bb.utils.unlockfile(lf)
diff --git a/meta/lib/oeqa/core/target/serial.py b/meta/lib/oeqa/core/target/serial.py
new file mode 100644
index 0000000000..7c2cd8b248
--- /dev/null
+++ b/meta/lib/oeqa/core/target/serial.py
@@ -0,0 +1,315 @@
1#
2# SPDX-License-Identifier: MIT
3#
4
5import base64
6import logging
7import os
8from threading import Lock
9from . import OETarget
10
11class OESerialTarget(OETarget):
12
13 def __init__(self, logger, target_ip, server_ip, server_port=0,
14 timeout=300, serialcontrol_cmd=None, serialcontrol_extra_args=None,
15 serialcontrol_ps1=None, serialcontrol_connect_timeout=None,
16 machine=None, **kwargs):
17 if not logger:
18 logger = logging.getLogger('target')
19 logger.setLevel(logging.INFO)
20 filePath = os.path.join(os.getcwd(), 'remoteTarget.log')
21 fileHandler = logging.FileHandler(filePath, 'w', 'utf-8')
22 formatter = logging.Formatter(
23 '%(asctime)s.%(msecs)03d %(levelname)s: %(message)s',
24 '%H:%M:%S')
25 fileHandler.setFormatter(formatter)
26 logger.addHandler(fileHandler)
27
28 super(OESerialTarget, self).__init__(logger)
29
30 if serialcontrol_ps1:
31 self.target_ps1 = serialcontrol_ps1
32 elif machine:
33 # fallback to a default value which assumes root@machine
34 self.target_ps1 = f'root@{machine}:.*# '
35 else:
36 raise ValueError("Unable to determine shell command prompt (PS1) format.")
37
38 if not serialcontrol_cmd:
39 raise ValueError("Unable to determine serial control command.")
40
41 if serialcontrol_extra_args:
42 self.connection_script = f'{serialcontrol_cmd} {serialcontrol_extra_args}'
43 else:
44 self.connection_script = serialcontrol_cmd
45
46 if serialcontrol_connect_timeout:
47 self.connect_timeout = serialcontrol_connect_timeout
48 else:
49 self.connect_timeout = 10 # default to 10s connection timeout
50
51 self.default_command_timeout = timeout
52 self.ip = target_ip
53 self.server_ip = server_ip
54 self.server_port = server_port
55 self.conn = None
56 self.mutex = Lock()
57
58 def start(self, **kwargs):
59 pass
60
61 def stop(self, **kwargs):
62 pass
63
64 def get_connection(self):
65 if self.conn is None:
66 self.conn = SerialConnection(self.connection_script,
67 self.target_ps1,
68 self.connect_timeout,
69 self.default_command_timeout)
70
71 return self.conn
72
73 def run(self, cmd, timeout=None):
74 """
75 Runs command on target over the provided serial connection.
76 The first call will open the connection, and subsequent
77 calls will re-use the same connection to send new commands.
78
79 command: Command to run on target.
80 timeout: <value>: Kill command after <val> seconds.
81 None: Kill command default value seconds.
82 0: No timeout, runs until return.
83 """
84 # Lock needed to avoid multiple threads running commands concurrently
85 # A serial connection can only be used by one caller at a time
86 with self.mutex:
87 conn = self.get_connection()
88
89 self.logger.debug(f"[Running]$ {cmd}")
90 # Run the command, then echo $? to get the command's return code
91 try:
92 output = conn.run_command(cmd, timeout)
93 status = conn.run_command("echo $?")
94 self.logger.debug(f" [stdout]: {output}")
95 self.logger.debug(f" [ret code]: {status}\n\n")
96 except SerialTimeoutException as e:
97 self.logger.debug(e)
98 output = ""
99 status = 255
100
101 # Return to $HOME after each command to simulate a stateless SSH connection
102 conn.run_command('cd "$HOME"')
103
104 return (int(status), output)
105
106 def copyTo(self, localSrc, remoteDst):
107 """
108 Copies files by converting them to base 32, then transferring
109 the ASCII text to the target, and decoding it in place on the
110 target.
111
112 On a 115k baud serial connection, this method transfers at
113 roughly 30kbps.
114 """
115 with open(localSrc, 'rb') as file:
116 data = file.read()
117
118 b32 = base64.b32encode(data).decode('utf-8')
119
120 # To avoid shell line limits, send a chunk at a time
121 SPLIT_LEN = 512
122 lines = [b32[i:i+SPLIT_LEN] for i in range(0, len(b32), SPLIT_LEN)]
123
124 with self.mutex:
125 conn = self.get_connection()
126
127 filename = os.path.basename(localSrc)
128 TEMP = f'/tmp/{filename}.b32'
129
130 # Create or empty out the temp file
131 conn.run_command(f'echo -n "" > {TEMP}')
132
133 for line in lines:
134 conn.run_command(f'echo -n {line} >> {TEMP}')
135
136 # Check to see whether the remoteDst is a directory
137 is_directory = conn.run_command(f'[[ -d {remoteDst} ]]; echo $?')
138 if int(is_directory) == 0:
139 # append the localSrc filename to the end of remoteDst
140 remoteDst = os.path.join(remoteDst, filename)
141
142 conn.run_command(f'base32 -d {TEMP} > {remoteDst}')
143 conn.run_command(f'rm {TEMP}')
144
145 return 0, 'Success'
146
147 def copyFrom(self, remoteSrc, localDst):
148 """
149 Copies files by converting them to base 32 on the target, then
150 transferring the ASCII text to the host. That text is then
151 decoded here and written out to the destination.
152
153 On a 115k baud serial connection, this method transfers at
154 roughly 30kbps.
155 """
156 with self.mutex:
157 b32 = self.get_connection().run_command(f'base32 {remoteSrc}')
158
159 data = base64.b32decode(b32.replace('\r\n', ''))
160
161 # If the local path is a directory, get the filename from
162 # the remoteSrc path and append it to localDst
163 if os.path.isdir(localDst):
164 filename = os.path.basename(remoteSrc)
165 localDst = os.path.join(localDst, filename)
166
167 with open(localDst, 'wb') as file:
168 file.write(data)
169
170 return 0, 'Success'
171
172 def copyDirTo(self, localSrc, remoteDst):
173 """
174 Copy recursively localSrc directory to remoteDst in target.
175 """
176
177 for root, dirs, files in os.walk(localSrc):
178 # Create directories in the target as needed
179 for d in dirs:
180 tmpDir = os.path.join(root, d).replace(localSrc, "")
181 newDir = os.path.join(remoteDst, tmpDir.lstrip("/"))
182 cmd = "mkdir -p %s" % newDir
183 self.run(cmd)
184
185 # Copy files into the target
186 for f in files:
187 tmpFile = os.path.join(root, f).replace(localSrc, "")
188 dstFile = os.path.join(remoteDst, tmpFile.lstrip("/"))
189 srcFile = os.path.join(root, f)
190 self.copyTo(srcFile, dstFile)
191
192 def deleteFiles(self, remotePath, files):
193 """
194 Deletes files in target's remotePath.
195 """
196
197 cmd = "rm"
198 if not isinstance(files, list):
199 files = [files]
200
201 for f in files:
202 cmd = "%s %s" % (cmd, os.path.join(remotePath, f))
203
204 self.run(cmd)
205
206 def deleteDir(self, remotePath):
207 """
208 Deletes target's remotePath directory.
209 """
210
211 cmd = "rmdir %s" % remotePath
212 self.run(cmd)
213
214 def deleteDirStructure(self, localPath, remotePath):
215 """
216 Delete recursively localPath structure directory in target's remotePath.
217
218 This function is useful to delete a package that is installed in the
219 device under test (DUT) and the host running the test has such package
220 extracted in tmp directory.
221
222 Example:
223 pwd: /home/user/tmp
224 tree: .
225 └── work
226 ├── dir1
227 │   └── file1
228 └── dir2
229
230 localpath = "/home/user/tmp" and remotepath = "/home/user"
231
232 With the above variables this function will try to delete the
233 directory in the DUT in this order:
234 /home/user/work/dir1/file1
235 /home/user/work/dir1 (if dir is empty)
236 /home/user/work/dir2 (if dir is empty)
237 /home/user/work (if dir is empty)
238 """
239
240 for root, dirs, files in os.walk(localPath, topdown=False):
241 # Delete files first
242 tmpDir = os.path.join(root).replace(localPath, "")
243 remoteDir = os.path.join(remotePath, tmpDir.lstrip("/"))
244 self.deleteFiles(remoteDir, files)
245
246 # Remove dirs if empty
247 for d in dirs:
248 tmpDir = os.path.join(root, d).replace(localPath, "")
249 remoteDir = os.path.join(remotePath, tmpDir.lstrip("/"))
250 self.deleteDir(remoteDir)
251
252class SerialTimeoutException(Exception):
253 def __init__(self, msg):
254 self.msg = msg
255 def __str__(self):
256 return self.msg
257
258class SerialConnection:
259
260 def __init__(self, script, target_prompt, connect_timeout, default_command_timeout):
261 import pexpect # limiting scope to avoid build dependency
262 self.prompt = target_prompt
263 self.connect_timeout = connect_timeout
264 self.default_command_timeout = default_command_timeout
265 self.conn = pexpect.spawn('/bin/bash', ['-c', script], encoding='utf8')
266 self._seek_to_clean_shell()
267 # Disable echo to avoid the need to parse the outgoing command
268 self.run_command('stty -echo')
269
270 def _seek_to_clean_shell(self):
271 """
272 Attempts to find a clean shell, meaning it is clear and
273 ready to accept a new command. This is necessary to ensure
274 the correct output is captured from each command.
275 """
276 import pexpect # limiting scope to avoid build dependency
277 # Look for a clean shell
278 # Wait a short amount of time for the connection to finish
279 pexpect_code = self.conn.expect([self.prompt, pexpect.TIMEOUT],
280 timeout=self.connect_timeout)
281
282 # if a timeout occurred, send an empty line and wait for a clean shell
283 if pexpect_code == 1:
284 # send a newline to clear and present the shell
285 self.conn.sendline("")
286 pexpect_code = self.conn.expect(self.prompt)
287
288 def run_command(self, cmd, timeout=None):
289 """
290 Runs command on target over the provided serial connection.
291 Returns any output on the shell while the command was run.
292
293 command: Command to run on target.
294 timeout: <value>: Kill command after <val> seconds.
295 None: Kill command default value seconds.
296 0: No timeout, runs until return.
297 """
298 import pexpect # limiting scope to avoid build dependency
299 # Convert from the OETarget defaults to pexpect timeout values
300 if timeout is None:
301 timeout = self.default_command_timeout
302 elif timeout == 0:
303 timeout = None # passing None to pexpect is infinite timeout
304
305 self.conn.sendline(cmd)
306 pexpect_code = self.conn.expect([self.prompt, pexpect.TIMEOUT], timeout=timeout)
307
308 # check for timeout
309 if pexpect_code == 1:
310 self.conn.send('\003') # send Ctrl+C
311 self._seek_to_clean_shell()
312 raise SerialTimeoutException(f'Timeout executing: {cmd} after {timeout}s')
313
314 return self.conn.before.removesuffix('\r\n')
315
diff --git a/meta/lib/oeqa/core/target/ssh.py b/meta/lib/oeqa/core/target/ssh.py
index 09cdd14c75..8b5c450a05 100644
--- a/meta/lib/oeqa/core/target/ssh.py
+++ b/meta/lib/oeqa/core/target/ssh.py
@@ -55,14 +55,14 @@ class OESSHTarget(OETarget):
55 def stop(self, **kwargs): 55 def stop(self, **kwargs):
56 pass 56 pass
57 57
58 def _run(self, command, timeout=None, ignore_status=True): 58 def _run(self, command, timeout=None, ignore_status=True, raw=False):
59 """ 59 """
60 Runs command in target using SSHProcess. 60 Runs command in target using SSHProcess.
61 """ 61 """
62 self.logger.debug("[Running]$ %s" % " ".join(command)) 62 self.logger.debug("[Running]$ %s" % " ".join(command))
63 63
64 starttime = time.time() 64 starttime = time.time()
65 status, output = SSHCall(command, self.logger, timeout) 65 status, output = SSHCall(command, self.logger, timeout, raw)
66 self.logger.debug("[Command returned '%d' after %.2f seconds]" 66 self.logger.debug("[Command returned '%d' after %.2f seconds]"
67 "" % (status, time.time() - starttime)) 67 "" % (status, time.time() - starttime))
68 68
@@ -72,7 +72,7 @@ class OESSHTarget(OETarget):
72 72
73 return (status, output) 73 return (status, output)
74 74
75 def run(self, command, timeout=None, ignore_status=True): 75 def run(self, command, timeout=None, ignore_status=True, raw=False):
76 """ 76 """
77 Runs command in target. 77 Runs command in target.
78 78
@@ -91,8 +91,11 @@ class OESSHTarget(OETarget):
91 else: 91 else:
92 processTimeout = self.timeout 92 processTimeout = self.timeout
93 93
94 status, output = self._run(sshCmd, processTimeout, ignore_status) 94 status, output = self._run(sshCmd, processTimeout, ignore_status, raw)
95 self.logger.debug('Command: %s\nStatus: %d Output: %s\n' % (command, status, output)) 95 if len(output) > (64 * 1024):
96 self.logger.debug('Command: %s\nStatus: %d Output length: %s\n' % (command, status, len(output)))
97 else:
98 self.logger.debug('Command: %s\nStatus: %d Output: %s\n' % (command, status, output))
96 99
97 return (status, output) 100 return (status, output)
98 101
@@ -206,22 +209,23 @@ class OESSHTarget(OETarget):
206 remoteDir = os.path.join(remotePath, tmpDir.lstrip("/")) 209 remoteDir = os.path.join(remotePath, tmpDir.lstrip("/"))
207 self.deleteDir(remoteDir) 210 self.deleteDir(remoteDir)
208 211
209def SSHCall(command, logger, timeout=None, **opts): 212def SSHCall(command, logger, timeout=None, raw=False, **opts):
210 213
211 def run(): 214 def run():
212 nonlocal output 215 nonlocal output
213 nonlocal process 216 nonlocal process
214 output_raw = b'' 217 output_raw = bytearray()
215 starttime = time.time() 218 starttime = time.time()
219 progress = time.time()
216 process = subprocess.Popen(command, **options) 220 process = subprocess.Popen(command, **options)
217 has_timeout = False 221 has_timeout = False
222 appendline = None
218 if timeout: 223 if timeout:
219 endtime = starttime + timeout 224 endtime = starttime + timeout
220 eof = False 225 eof = False
221 os.set_blocking(process.stdout.fileno(), False) 226 os.set_blocking(process.stdout.fileno(), False)
222 while not has_timeout and not eof: 227 while not has_timeout and not eof:
223 try: 228 try:
224 logger.debug('Waiting for process output: time: %s, endtime: %s' % (time.time(), endtime))
225 if select.select([process.stdout], [], [], 5)[0] != []: 229 if select.select([process.stdout], [], [], 5)[0] != []:
226 # wait a bit for more data, tries to avoid reading single characters 230 # wait a bit for more data, tries to avoid reading single characters
227 time.sleep(0.2) 231 time.sleep(0.2)
@@ -229,9 +233,9 @@ def SSHCall(command, logger, timeout=None, **opts):
229 if not data: 233 if not data:
230 eof = True 234 eof = True
231 else: 235 else:
232 output_raw += data 236 output_raw.extend(data)
233 # ignore errors to capture as much as possible 237 # ignore errors to capture as much as possible
234 logger.debug('Partial data from SSH call:\n%s' % data.decode('utf-8', errors='ignore')) 238 #logger.debug('Partial data from SSH call:\n%s' % data.decode('utf-8', errors='ignore'))
235 endtime = time.time() + timeout 239 endtime = time.time() + timeout
236 except InterruptedError: 240 except InterruptedError:
237 logger.debug('InterruptedError') 241 logger.debug('InterruptedError')
@@ -244,6 +248,10 @@ def SSHCall(command, logger, timeout=None, **opts):
244 logger.debug('SSHCall has timeout! Time: %s, endtime: %s' % (time.time(), endtime)) 248 logger.debug('SSHCall has timeout! Time: %s, endtime: %s' % (time.time(), endtime))
245 has_timeout = True 249 has_timeout = True
246 250
251 if time.time() >= (progress + 60):
252 logger.debug('Waiting for process output at time: %s with datasize: %s' % (time.time(), len(output_raw)))
253 progress = time.time()
254
247 process.stdout.close() 255 process.stdout.close()
248 256
249 # process hasn't returned yet 257 # process hasn't returned yet
@@ -256,17 +264,29 @@ def SSHCall(command, logger, timeout=None, **opts):
256 logger.debug('OSError when killing process') 264 logger.debug('OSError when killing process')
257 pass 265 pass
258 endtime = time.time() - starttime 266 endtime = time.time() - starttime
259 lastline = ("\nProcess killed - no output for %d seconds. Total" 267 appendline = ("\nProcess killed - no output for %d seconds. Total"
260 " running time: %d seconds." % (timeout, endtime)) 268 " running time: %d seconds." % (timeout, endtime))
261 logger.debug('Received data from SSH call:\n%s ' % lastline) 269 logger.debug('Received data from SSH call:\n%s ' % appendline)
262 output += lastline
263 process.wait() 270 process.wait()
264 271
272 if raw:
273 output = bytes(output_raw)
274 if appendline:
275 output += bytes(appendline, "utf-8")
276 else:
277 output = output_raw.decode('utf-8', errors='ignore')
278 if appendline:
279 output += appendline
265 else: 280 else:
266 output_raw = process.communicate()[0] 281 output = output_raw = process.communicate()[0]
282 if not raw:
283 output = output_raw.decode('utf-8', errors='ignore')
267 284
268 output = output_raw.decode('utf-8', errors='ignore') 285 if len(output) < (64 * 1024):
269 logger.debug('Data from SSH call:\n%s' % output.rstrip()) 286 if output.rstrip():
287 logger.debug('Data from SSH call:\n%s' % output.rstrip())
288 else:
289 logger.debug('No output from SSH call')
270 290
271 # timout or not, make sure process exits and is not hanging 291 # timout or not, make sure process exits and is not hanging
272 if process.returncode == None: 292 if process.returncode == None:
@@ -292,7 +312,7 @@ def SSHCall(command, logger, timeout=None, **opts):
292 312
293 options = { 313 options = {
294 "stdout": subprocess.PIPE, 314 "stdout": subprocess.PIPE,
295 "stderr": subprocess.STDOUT, 315 "stderr": subprocess.STDOUT if not raw else None,
296 "stdin": None, 316 "stdin": None,
297 "shell": False, 317 "shell": False,
298 "bufsize": -1, 318 "bufsize": -1,
@@ -320,4 +340,4 @@ def SSHCall(command, logger, timeout=None, **opts):
320 logger.debug('Something went wrong, killing SSH process') 340 logger.debug('Something went wrong, killing SSH process')
321 raise 341 raise
322 342
323 return (process.returncode, output.rstrip()) 343 return (process.returncode, output if raw else output.rstrip())
diff --git a/meta/lib/oeqa/core/tests/common.py b/meta/lib/oeqa/core/tests/common.py
index 88cc758ad3..bcc4fde632 100644
--- a/meta/lib/oeqa/core/tests/common.py
+++ b/meta/lib/oeqa/core/tests/common.py
@@ -9,7 +9,6 @@ import os
9 9
10import unittest 10import unittest
11import logging 11import logging
12import os
13 12
14logger = logging.getLogger("oeqa") 13logger = logging.getLogger("oeqa")
15logger.setLevel(logging.INFO) 14logger.setLevel(logging.INFO)