1# Copyright 2020 The Chromium OS Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5"""Test to validate servo.py log grabbing works.""" 6 7import logging 8import os 9import re 10import tempfile 11import shutil 12 13from autotest_lib.client.common_lib import error 14from autotest_lib.server import test 15 16class servo_LogGrab(test.test): 17 """Ensure all logs are grabbed and glued together.""" 18 version = 1 19 20 # Regex used to extract log-record timestamp from log-line 21 # An example log record timestamp is: 22 # '2020-02-05 12:39:28,394 - EC - EC3PO.Interpreter'[..] 23 # This regex does a loose check on the date format as it does not 24 # enforce real month numbers for example. 25 # Note that the millisecond component is not part of the match group. 26 # This is because it can happen that two log messages compete for the 27 # file-print and one that was recorded a few milliseconds prior still 28 # makes it to the file first. 29 TS_RE = r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d{3} .*$' 30 31 TS_EXTRACTOR = re.compile(TS_RE) 32 33 def setup_dir(self): 34 """Setup a test directory to grab servo logs into and out of.""" 35 self.logtestdir = tempfile.mkdtemp() 36 37 def grab_and_get_directory(self): 38 """Thin wrapper around servo_host instance_logs function. 39 40 @returns: directory name inside |self.logtestdir| containing servod logs 41 None if no directory was created (and logs grabbed) 42 43 @raises error.TestFail: if log grabbing creates more or less than one 44 directory 45 """ 46 ts = self.servo_host.get_instance_logs_ts() 47 self.servo_host.get_instance_logs(ts, self.logtestdir) 48 dirs_created = len(os.listdir(self.logtestdir)) 49 if dirs_created > 1: 50 raise error.TestFail('Log grabbing created more than one directory') 51 if dirs_created == 0: 52 # None to indicate no logs were grabbed. 53 return None 54 return os.path.join(self.logtestdir, os.listdir(self.logtestdir)[0]) 55 56 def check_logs_ordered(self, outdir): 57 """Helper to check that all logs in |outdir| are concat in order. 58 59 Log grabbing glues together all logs for one loglevel into one 60 file. These files ought to be in order. 61 62 @param outdir: directory with logs to inspect 63 64 @raises error.TestFail: if any log in |outdir| contains unsorted log 65 lines 66 """ 67 for logf in os.listdir(outdir): 68 if not logf.startswith(self.servo_host.JOINT_LOG_PREFIX): 69 # MCU files are to be skipped. 70 continue 71 logpath = os.path.join(outdir, logf) 72 timestamps = [] 73 with open(logpath, 'r') as log: 74 for line in log: 75 ts_match = self.TS_EXTRACTOR.match(line) 76 if ts_match: 77 # It's safe to skip lines without timestamps, as those 78 # are just linebreaks in the log record. 79 timestamps.append(ts_match.group(1)) 80 if timestamps != sorted(timestamps): 81 raise error.TestFail('Logfile %s contained unsorted ' 82 'timestamps.' % logf) 83 84 def check_get_control_in_logs(self, outdir, ctrls): 85 """Helper to check debug logs in |outdir|. 86 87 This method always checks to ensure that the init logline saying that 88 servod is listening on a port is printed. 89 90 Note: if a control has been issued n times, and the goal is to ensure 91 that it shows up n times in the logs, add it n times to |ctrls|. 92 93 @param outdir: directory with logs to inspect 94 @param ctrls: list of get controls that have been issued and should be 95 checked for 96 97 @returns: False: - if any of the controls in |ctrls| are not found in 98 the logs 99 - if the log line saying that servod is listening on a 100 port is missing 101 True: otherwise 102 """ 103 artifacts = [re.compile(r'- \(get\) %s' % ctrl) for ctrl in ctrls] 104 # We also need to ensure that the initial logs don't get lost. 105 init_artifact = re.compile('Listening on (\d{1,3}\.){3}\d{1,3} ' 106 'port \d{1,5}') 107 artifacts.append(init_artifact) 108 fname = os.path.join(outdir, 'log.DEBUG.txt') 109 with open(fname, 'r') as f: 110 for line in f: 111 # Only maintain the artifacts that have had no match yet. 112 new_artifacts = [] 113 found = False 114 for artifact in artifacts: 115 if not found and artifact.search(line): 116 # Each artifact is for one log-line. If a line is 117 # supposed to appear x times, there should be x 118 # artifacts, therefore we need to continue to avoid 119 # undercounting. 120 found = True 121 continue 122 # No match, add back to list. 123 new_artifacts.append(artifact) 124 if not new_artifacts: 125 # All found, so the log file checks out. 126 return True 127 artifacts = new_artifacts 128 logging.error('The following artifacts were not found in logs:') 129 for artifact in artifacts: 130 logging.error(artifact.pattern) 131 return False 132 133 134 def test_singular(self): 135 """Subtest to verify single-file grabbing works. 136 137 As for any new test, the servod instance should only have one file 138 upon initialization. This subtest ensures that the one file is grabbed 139 properly and found in the results directory. 140 141 @raises error.TestFail: if the log line saying that servod is listening 142 on a port is missing 143 @raises error.TestFail: one of the loglevel summaries is missing 144 """ 145 outdir = self.grab_and_get_directory() 146 required = ['log.%s.txt' % l for l in ['INFO', 'DEBUG', 'WARNING']] 147 logs = os.listdir(outdir) 148 for log in required: 149 if log not in logs: 150 raise error.TestFail('Required file %s missing. ' 151 'Log files found: %s' 152 % (log, ', '.join(logs))) 153 self.check_logs_ordered(outdir) 154 self.check_get_control_in_logs(outdir, []) 155 156 def test_dual(self): 157 """Subtest to verify singular file rotation works. 158 159 Test rotates the logs once and then checks integrity. 160 161 @raises error.TestFail: if the log line saying that servod is listening 162 on a port is missing 163 @raises error.TestFail: logline showing 'config_files' was issued is 164 missing 165 """ 166 logging.info('Running subtest to rotate logs once and validate that ' 167 'logs are glued together in the results directory ' 168 'correctly.') 169 # First, rotate out the logs. 170 self.servo.set_nocheck('rotate_servod_logs', 'yes') 171 # Next issue a control that (a) requires no underlying hardware, and 172 # (b) is not part of hwinit. 173 ctrl = 'config_files' 174 self.servo.get(ctrl) 175 outdir = self.grab_and_get_directory() 176 self.check_logs_ordered(outdir) 177 if not self.check_get_control_in_logs(outdir, [ctrl]): 178 raise error.TestFail('joint logfile log.DEBUG.txt does not contain ' 179 'log artifact that was created after the first' 180 ' rotation') 181 182 def test_compressed(self): 183 """Subtest to verify multi file rotation and compression works. 184 185 Test rotates the logs many times to generate compression. Validate 186 that the output is properly decompressed with all expected lines. 187 188 @raises error.TestFail: if the log line saying that servod is listening 189 on a port is missing 190 @raises error.TestFail: logline showing 'config_files' was issued is 191 missing 192 @raises error.TestFail: any of the expected number of loglines showing 193 'servod_pid' was issued is missing 194 """ 195 logging.info('Running subtest to rotate logs past the compression ' 196 'threshold and validate that compressed logs are ' 197 'correctly decompressed upon grabbing.') 198 # First, rotate out the logs. 199 self.servo.set_nocheck('rotate_servod_logs', 'yes') 200 ctrl = 'config_files' 201 # Next issue a control that (a) requires no underlying hardware, and 202 # (b) is not part of hwinit. 203 self.servo.get(ctrl) 204 ctrls = [ctrl] 205 # Then, rotate out the logs and generate traffic for 6 additional times. 206 # That should trigger file compression. 207 # The command here has to be different to ensure that the first file 208 # that had the config_files in it got decompressed. 209 rot_ctrl = 'servod_pid' 210 for _ in range(6): 211 self.servo.set_nocheck('rotate_servod_logs', 'yes') 212 self.servo.get(rot_ctrl) 213 # Append every time to ensure it shows up the exact number of 214 # times in the logs. 215 ctrls.append(rot_ctrl) 216 outdir = self.grab_and_get_directory() 217 self.check_logs_ordered(outdir) 218 if not self.check_get_control_in_logs(outdir, ctrls): 219 raise error.TestFail('joint logfile log.DEBUG.txt does not contain ' 220 'log artifact that was created after the ' 221 'multiple rotation that leads to compression.') 222 223 def test_missing(self): 224 """Subtest to verify missing symlinks will grab no logs, throw no error. 225 226 @raises error.TestFail: deleting symlinks to the latest logs causes 227 logs to still be grabbed 228 """ 229 logging.info('Running subtest to remove latest log symlinks and ' 230 'validate this results in a grab logs noop.') 231 self.servo_host.remove_latest_log_symlinks() 232 outdir = self.grab_and_get_directory() 233 if not outdir == None: 234 raise error.TestFail('Removing symlinks to latest logs still ' 235 'caused test to find logs.') 236 237 def test_restart(self): 238 """Subtest to verify that a servod restart in the middle is caught. 239 240 @raises error.TestFail: if the logs from the previous instances are 241 missing on a restart 242 """ 243 restarts = 2 244 # Need to cache the initial timestamp so that we can reuse it later 245 initial_ts = self.servo_host.get_instance_logs_ts() 246 for _ in range(restarts): 247 self.servo_host.restart_servod() 248 self.servo_host._initial_instance_ts = initial_ts 249 # Grab the logs and probe for old, orphaned logs 250 ts = self.servo_host.get_instance_logs_ts() 251 self.servo_host.probe_servod_restart(ts, self.logtestdir) 252 self.servo_host.get_instance_logs(ts, self.logtestdir) 253 # First, validate that in total 3 directories were created: one 254 # for the current instance, and two for the old ones 255 dirs_created = len(os.listdir(self.logtestdir)) 256 expected_dirs = restarts + 1 257 if dirs_created != expected_dirs: 258 logging.error('Should have created %d dirs from grabbing logs ' 259 'but only created %d.', expected_dirs, dirs_created) 260 raise error.TestFail('Grabbing logs from restarted instance failed') 261 # Check that panicinfo was called on the MCUs 262 # TODO(coconutruben): refactor this into one source of truth on which 263 # servos support panicinfo 264 # There should only ever be one directory here that doesn't have the 265 # suffix but if that logic changes in servo_host, then this needs to be 266 # refactored, or an API call added to servo_host 267 final_instance_dir = [d for d in os.listdir(self.logtestdir) if 268 self.servo_host.OLD_LOG_SUFFIX not in d][0] 269 final_instance_dir_path = os.path.join(self.logtestdir, 270 final_instance_dir) 271 for mcu in ['servo_v4', 'servo_micro']: 272 mcu_path = os.path.join(final_instance_dir_path, '%s.txt' % mcu) 273 if os.path.exists(mcu_path): 274 with open(mcu_path, 'r') as f: 275 if 'panicinfo' not in f.read(): 276 raise error.TestFail('No panicinfo call found in %r.' % 277 mcu_path) 278 def test_restart_no_servo(self): 279 """Subtest to verify that lack of |_servo| attribute yields no failure. 280 281 @raises error.TestFail: if the lack of _servo causes an issue 282 """ 283 self.servo_host.servo = None 284 try: 285 self.test_restart() 286 except AttributeError as e: 287 raise error.TestFail('Setting |_servo| to None caused an error. %s' 288 % str(e)) 289 except error.TestFail as e: 290 if 'No panicinfo call found in' in str(e): 291 # This means that the lack of |_servo| worked as intended e.g. 292 # the logs are grabbed, but the panicinfo could not be issued. 293 pass 294 # for any other issue, we need to actually raise it. 295 raise e 296 # Restore servo object again. 297 self.servo_host.servo = self.servo 298 299 def run_once(self, host): 300 """Try 3 scenarios of log grabbing: single, multiple, compressed.""" 301 self.servo_host = host._servo_host 302 self.servo = host.servo 303 # Note that test_missing should run last as it is destructive and 304 # removes logs on the servo_host side. Alternatively, restart 305 # servod if another destructive test is added. 306 for subtest in [self.test_singular, self.test_dual, 307 self.test_compressed, self.test_restart, 308 self.test_restart_no_servo, self.test_missing]: 309 self.setup_dir() 310 subtest() 311 logging.info('Success') 312 self.cleanup_logs() 313 314 def cleanup_logs(self): 315 """Delete the temporary directory.""" 316 shutil.rmtree(self.logtestdir) 317