blob: db962ff76350071a0eaa46d3af10e3be76e1dca7 [file] [log] [blame]
Chinglin Yue559b122021-01-07 18:56:06 +08001#!/usr/bin/env python3
2
3# Copyright 2021 The Chromium OS Authors. All rights reserved.
4# Use of this source code is governed by a BSD-style license that can be
5# found in the LICENSE file.
6"""Wrapper to run the platform.BootPerf tast test.
7
8This script runs the 'platform.BootPerf' boot timing test and store the results
9for later analysis by the 'showbootdata' script.
10
11NOTE: This script must be run from inside the chromeos build chroot
12environment.
13"""
14
15import argparse
16import datetime
17from distutils import dir_util
18from distutils import file_util
19import glob
20import os
21import os.path
22import re
23import shutil
24import stat
25import subprocess
26import sys
27import tempfile
28import textwrap
29
30# Tast tmp results (/tmp/bootperf.XXXXXX) structure:
31# device-config.txt
32# full.txt
33# results.json
34# streamed_results.jsonl
35# system_logs/
36# tests/
37# platform.BootPerf/
38# log.txt
39# results-chart.json
40# raw.001/
41# raw.002/
42# (one dir of raw data for each boot iteration)
43# timing.json
44
45# Constants
46_TEST = 'platform.BootPerf'
47_RESULTS_DIR = f'tests/{_TEST}'
48_RESULTS_KEYVAL = f'{_RESULTS_DIR}/results-chart.json'
49_RESULTS_SUMMARY = 'results_json'
50_RESULTS_SUMMARY_FILES = [
51 _RESULTS_DIR,
52 'device-config.txt',
53 'full.txt',
54 'results.json',
55 'streamed_results.jsonl',
56 'timing.json',
57]
58_RESULTS_SUMMARY_FILES_RAW_GLOB = f'{_RESULTS_DIR}/raw.*'
59
60# Structure of a results directory:
61# $RESULTS_SUMMARY/ - file
62# $RUNDIR.$ITER/ - directory
63# $RUNDIR_LOG - file
64# $RUNDIR_SUMMARY/ - directory
65# $RUNDIR_ALL_RESULTS/ - optional directory
66# If you add any other content under the results directory, you'll
67# probably need to change extra_files(), below.
68_RUNDIR = 'run'
69_RUNDIR_LOG = 'log.txt'
70_RUNDIR_SUMMARY = 'summary'
71_RUNDIR_ALL_RESULTS = 'logs'
72
73_DESCRIPTION = """\
74Summary:
75 Run the {} tast test, and store results in the
76 given destination directory. The test target is specified by
77 <ip_address>.
78
79 By default, the test is run once; if <count> is given, the test is
80 run that many times. By default, each test run reboots the test target 10
81 times, and this can be overridden using the [-r REBOOT_ITERATIONS] option.
82
83 If the destination directory doesn't exist, it is created. If the
84 destination directory already holds test results, additional
85 results are added in without overwriting earlier results.
86
87 If no destination is specified, the current directory is used,
88 provided that the directory is empty, or has been previously used
89 as a destination directory for this command.
90
91 By default, only a summary subset of the log files created by
92 tast are preserved; with --keep_logs the (potentially large)
93 logs are preserved with the test result.
94""".format(_TEST)
95
96
97def print_error(error):
98 """A utility function for printing a color-highlighted error if possible."""
99 cred = '\033[1;31;40m'
100 cend = '\033[0m'
101 if hasattr(sys.stdout, 'isatty') and sys.stdout.isatty():
102 error = f'{cred}ERROR: {error}{cend}'
103 print(error)
104
105
106def _assert_in_chroot():
107 """Asserts that we are inside the cros chroot."""
108 if not os.path.exists('/etc/cros_chroot_version'):
109 print_error(
110 textwrap.dedent("""\
111 This script must be run inside the chroot. Run this first:
112 cros_sdk
113 """))
114 sys.exit(1)
115
116
117class BootPerf:
118 """Runs the boot timing tests.
119
120 This class drives the execution running the boot timing test:
121 * Parse and validate command line arguments.
122 * Handle working and output directories.
123 * Run the platform.BootPerf tast test.
124 * Collect test results.
125 """
126
127 def __init__(self):
128 self.parser = None
129 self.args = None
130 self.count = None
131 self.current_iter = None
132 self.output_dir = None
133 self.tmp_dir = None
134
135 def process_cmdline(self):
136 """Process the command line arguments."""
137 parser = argparse.ArgumentParser(
138 epilog=_DESCRIPTION,
139 formatter_class=argparse.RawDescriptionHelpFormatter)
140 # Positional arguments: ip-address and (optional) count.
141 parser.add_argument('ip_address', help='Address of the test target')
142 parser.add_argument(
143 'count',
144 nargs='?',
145 help='The number of iterations (default: %(default)s)',
146 type=int,
147 default=1)
148 parser.add_argument(
149 '-o',
150 '--output_dir',
151 help='Specify output directory for results')
152 parser.add_argument(
153 '-k',
154 '--keep_logs',
155 help='Keep tast log files',
156 action='store_true')
157 parser.add_argument(
158 '-r',
159 '--reboot_iterations',
160 help='Specify the number of reboots in each iteration ' \
161 '(default: %(default)s)',
162 type=int,
163 default=10)
164 self.parser = parser
165 self.args = parser.parse_args()
166
167 def validate_args(self):
168 """Utility for validating command line arguments."""
169 if self.args.count <= 0:
170 self.print_usage('<count> argument must be a positive number')
171 self.count = self.args.count
172
173 if self.args.reboot_iterations <= 0:
174 self.print_usage(
175 '[-r REBOOT_ITERATIONS] argument must be a positive number')
176
177 def print_usage(self, error=None):
178 """Prints usage help message and terminates the script."""
179 self.parser.print_help()
180 if error:
181 print_error(error)
182 sys.exit(1)
183
184 def _validate_output_dir(self):
185 """Check for extra files in the output dir other than _RUNDIR ones.
186
187 Also gets the current iteration number.
188 """
189 max_iter = 0
190 for entry in os.listdir(self.output_dir):
191 basename = os.path.basename(entry)
192 if basename == _RESULTS_SUMMARY:
193 continue
194 matches = re.match(_RUNDIR + r'\.(\d+)', basename)
195 if matches is None:
196 print_error(
197 textwrap.dedent("""\
198 No results directory specified, and current directory
199 contains contents other than run results.
200 You can override this error by using the --output_dir option
201 """))
202 self.print_usage()
203 # Update to find the current iteration.
204 max_iter = max(max_iter, int(matches.group(1)))
205
206 self.current_iter = max_iter + 1
207
208 def _current_iter_str(self):
209 """Utility for converting the current iteration numbder string.
210
211 Returns:
212 The current iteration number as a string.
213 """
214 return '{:03d}'.format(self.current_iter)
215
216 def prepare_directories(self):
217 """Prepares the working temp and output directories for the test."""
218 self._process_output_dir()
219 self._validate_output_dir()
Chinglin Yue559b122021-01-07 18:56:06 +0800220
221 def _process_output_dir(self):
222 """Creates the output dir or use the current working dir for test output."""
223 if self.args.output_dir is not None:
224 if not os.path.exists(self.args.output_dir):
225 try:
226 os.mkdir(self.args.output_dir)
227 except OSError:
228 self.print_usage(f'Unable to create {self.args.output_dir}')
229 self.output_dir = self.args.output_dir
230 else:
231 self.output_dir = os.getcwd()
232
233 def _make_tmp_dir(self):
234 """Creates a temp directory as the test working dir."""
235 self.tmp_dir = tempfile.mkdtemp(prefix='bootperf.')
236
237 def _copy_results_summary(self, dst_dir):
238 """Copies the summary of test artifacts."""
239 for res in _RESULTS_SUMMARY_FILES:
240 src = os.path.join(self.tmp_dir, res)
241 dst = os.path.join(dst_dir, res)
242
243 assert os.path.isdir(src) or os.path.isfile(src)
244
245 if os.path.isdir(src):
246 dir_util.copy_tree(src, dst)
247 else:
248 file_util.copy_file(src, dst)
249 # The reboots dir contains archives of syslog messages for each reboot
250 # and can be potentially large. Remove it from the summary directory.
251 shutil.rmtree(os.path.join(dst_dir, _RESULTS_DIR, 'reboots'))
252
253 def _run_boot_test_once(self):
254 """Run the platform.BootPerf tast test once."""
255 remote = self.args.ip_address
Chinglin Yuc306e292021-04-27 15:13:58 +0800256 # |iter_rundir| is the absolute path of the run.??? directory for the
257 # current iteration.
258 iter_rundir = os.path.join(
259 self.output_dir,
260 f'{_RUNDIR}.{self._current_iter_str()}')
261 logfile = os.path.join(iter_rundir, _RUNDIR_LOG)
Chinglin Yue559b122021-01-07 18:56:06 +0800262 summary_dir = os.path.join(iter_rundir, _RUNDIR_SUMMARY)
263 all_results_dir = os.path.join(iter_rundir, _RUNDIR_ALL_RESULTS)
264
Chinglin Yu96281102021-03-05 17:21:18 +0800265 self._make_tmp_dir()
Chinglin Yue559b122021-01-07 18:56:06 +0800266 os.mkdir(iter_rundir)
267 time_now = datetime.datetime.now().strftime('%H:%M:%S')
268 print(f'Test started: {time_now} - {logfile}')
269
270 # bootperf is typically run by devs in local tests, where rootfs
271 # verification is often disabled. Disable the assertion of rootfs
272 # verification in tast.platform.BootPerf.
273 skiprootfs_check = '-var=platform.BootPerf.skipRootfsCheck=true'
274 # Test option of the number of reboots in each test run. Default is 10.
275 # Note that the test runs for <count> times so the total number of reboots
276 # will be 10*<count>.
277 iterations = '-var=platform.BootPerf.iterations={}'.format(
278 self.args.reboot_iterations)
279
280 tast_args = [
281 'tast',
282 'run',
283 f'--resultsdir={self.tmp_dir}',
284 skiprootfs_check,
285 iterations,
286 remote,
287 _TEST,
288 ]
289 with open(logfile, 'w') as output:
290 subprocess.call(
291 tast_args, stdout=output, stderr=output, cwd=self.output_dir)
292
293 if not os.path.exists(os.path.join(self.tmp_dir, _RESULTS_KEYVAL)):
294 print_error(
295 textwrap.dedent("""\
296 No results file; terminating test runs.
297 Check {} for output from the test run,
298 and see {} for full test logs and output.
299 """.format(logfile, self.tmp_dir)))
300 sys.exit(1)
301
302 os.mkdir(summary_dir)
303 self._copy_results_summary(summary_dir)
304
305 if self.args.keep_logs:
306 shutil.move(self.tmp_dir, all_results_dir)
307 os.chmod(
308 all_results_dir, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR
309 | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
310 else:
311 shutil.rmtree(self.tmp_dir)
Chinglin Yue559b122021-01-07 18:56:06 +0800312
313 self.current_iter += 1
314
315 def _copy_all_results_summary(self):
316 """Utility to copy all results-chart.json into the summary file."""
317 with open(os.path.join(self.output_dir, _RESULTS_SUMMARY), 'w') as outf:
318 for path in glob.glob(
319 os.path.join(f'{_RUNDIR}.???', _RUNDIR_SUMMARY, _RESULTS_KEYVAL)):
320 with open(path) as inf:
321 for line in inf:
322 outf.write(line)
323
324 def run_boot_test(self):
325 """Main function to run the boot performance test.
326
327 Run the boot performance test for the given count, putting output into the
328 current directory.
329
330 Arguments are <ip-address> and <count> arguments, as for the main command.
331
332 We terminate test runs if the _RESULTS_SUMMARY file isn't produced;
333 generally this is the result of a serious error (e.g. disk full) that
334 won't go away if we just plow on.
335 """
336 for _ in range(self.count):
337 self._run_boot_test_once()
338
339 print('Test finished:', datetime.datetime.now().strftime('%H:%M:%S'))
340 self._copy_all_results_summary()
341
342
343def main():
344 _assert_in_chroot()
345
346 boot_perf = BootPerf()
347 boot_perf.process_cmdline()
348 boot_perf.validate_args()
349 boot_perf.prepare_directories()
350 boot_perf.run_boot_test()
351
352
353if __name__ == '__main__':
354 main()