Skip to content

Commit 30f1deb

Browse files
feat: enable immediate log dumping on component failure
1 parent 5b4e837 commit 30f1deb

File tree

1 file changed

+132
-48
lines changed

1 file changed

+132
-48
lines changed

hybrid-cloud-poc/ci_test_runner.py

Lines changed: 132 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class Colors:
1919
CYAN = '\033[0;36m'
2020
BOLD = '\033[1m'
2121
NC = '\033[0m' # No Color
22-
22+
2323
@classmethod
2424
def disable(cls):
2525
"""Disable colors for CI environments"""
@@ -34,18 +34,51 @@ def __init__(self, args=None, no_color=False):
3434
self.log_dir = None
3535
self.errors = []
3636
self.warnings = []
37-
37+
self.current_log_file = None
38+
self.dumped_log_files = set()
39+
3840
if no_color or not sys.stdout.isatty():
3941
Colors.disable()
40-
42+
4143
def extract_log_dir(self, line):
4244
"""Extract log directory from test output"""
43-
match = re.search(r'Logs will be aggregated in (/tmp/unified_identity_test_\d+)', line)
45+
match = re.search(r'Logs will be aggregated in (/tmp/unified_identity_test_\d+_\d+)', line)
4446
if match:
4547
self.log_dir = match.group(1)
4648
return True
4749
return False
50+
51+
def track_log_file(self, line):
52+
"""Track the currently active log file from output"""
53+
# Matches "Log: /tmp/.../foo.log"
54+
match = re.search(r'Log:\s+([^ ]+\.log)', line)
55+
if match:
56+
self.current_log_file = match.group(1).strip()
57+
return True
58+
return False
59+
60+
def dump_current_log_tail(self):
61+
"""Immediately dump the tail of the current log file if not already dumped"""
62+
if not self.current_log_file or self.current_log_file in self.dumped_log_files:
63+
return
4864

65+
log_path = Path(self.current_log_file)
66+
if not log_path.exists():
67+
return
68+
69+
self.dumped_log_files.add(self.current_log_file)
70+
print(f"\n{Colors.YELLOW}--- IMMEDIATE LOG DUMP: {log_path.name} ---{Colors.NC}")
71+
try:
72+
with open(log_path, 'r') as f:
73+
# Read all lines efficiently
74+
lines = f.readlines()
75+
# Show last 50 lines to keep it focused
76+
tail_lines = lines[-50:] if len(lines) > 50 else lines
77+
print(''.join(tail_lines))
78+
except Exception as e:
79+
print(f"Error reading log file: {e}")
80+
print(f"{Colors.YELLOW}--- END LOG DUMP ---\n{Colors.NC}")
81+
4982
def detect_error(self, line):
5083
"""Detect error patterns in output"""
5184
# Ignore expected errors
@@ -58,29 +91,30 @@ def detect_error(self, line):
5891
for pattern in ignore_patterns:
5992
if re.search(pattern, line, re.IGNORECASE):
6093
return False
61-
94+
6295
# Real error patterns
6396
error_patterns = [
6497
r'CRITICAL ERROR',
6598
r'FAILED.*test',
6699
r'cannot.*connect',
67100
r'Unable to start',
68101
r'Exit.*code.*[1-9]', # Non-zero exit from subprocess
102+
r'Error:', # Catch generic Error: lines
69103
]
70104
for pattern in error_patterns:
71105
if re.search(pattern, line, re.IGNORECASE):
72106
self.errors.append(line.strip())
73107
return True
74-
108+
75109
# Check for ✗ symbol (failure indicator) but not in cleanup-related messages
76110
if '✗' in line:
77111
if any(pattern in line.lower() for pattern in ['cleanup', 'stopping', 'cleaning up']):
78112
return False # Ignore cleanup failures
79113
self.errors.append(line.strip())
80114
return True
81-
115+
82116
return False
83-
117+
84118
def detect_warning(self, line):
85119
"""Detect warning patterns in output"""
86120
# Ignore very noisy warnings
@@ -91,7 +125,7 @@ def detect_warning(self, line):
91125
for pattern in ignore_patterns:
92126
if re.search(pattern, line, re.IGNORECASE):
93127
return False
94-
128+
95129
warning_patterns = [
96130
r'WARNING',
97131
r'⚠.*(?!Agent services cleanup)', # Warnings except cleanup
@@ -101,7 +135,7 @@ def detect_warning(self, line):
101135
self.warnings.append(line.strip())
102136
return True
103137
return False
104-
138+
105139
def print_header(self):
106140
"""Print CI run header"""
107141
print(f"{Colors.BOLD}{'='*80}{Colors.NC}")
@@ -111,32 +145,32 @@ def print_header(self):
111145
print(f"Command: ./test_integration.sh {' '.join(self.args)}")
112146
print(f"{Colors.BOLD}{'='*80}{Colors.NC}")
113147
print()
114-
148+
115149
def parse_log_files(self):
116150
"""Parse log files after completion to find failure details"""
117151
if not self.log_dir or not Path(self.log_dir).exists():
118152
return
119-
153+
120154
log_files = list(Path(self.log_dir).glob('*.log'))
121155
if not log_files:
122156
return
123-
157+
124158
# Parse master.log first for overall failure
125159
master_log = Path(self.log_dir) / 'master.log'
126160
if master_log.exists():
127161
self._parse_master_log(master_log)
128-
162+
129163
# Parse individual script logs for specific failures
130164
for log_file in sorted(log_files):
131165
if log_file.name != 'master.log':
132166
self._parse_script_log(log_file)
133-
167+
134168
def _parse_master_log(self, log_path):
135169
"""Parse master.log to find failure point"""
136170
try:
137171
with open(log_path, 'r') as f:
138172
lines = f.readlines()
139-
173+
140174
# Find the last error before exit
141175
for i in range(len(lines) - 1, max(0, len(lines) - 50), -1):
142176
line = lines[i]
@@ -145,77 +179,121 @@ def _parse_master_log(self, log_path):
145179
context_start = max(0, i - 5)
146180
context_end = min(len(lines), i + 6)
147181
context = ''.join(lines[context_start:context_end])
148-
182+
149183
if line.strip() not in [e.strip() for e in self.errors]:
150184
self.errors.append(f"[master.log:{i+1}] {line.strip()}")
151185
break
152186
except Exception as e:
153187
pass # Ignore parsing errors
154-
188+
155189
def _parse_script_log(self, log_path):
156190
"""Parse individual script log to find failure"""
157191
try:
158192
with open(log_path, 'r') as f:
159193
lines = f.readlines()
160-
194+
161195
# Look for explicit error markers
162196
for i, line in enumerate(lines):
163197
if any(pattern in line for pattern in ['✗', 'failed', 'CRITICAL ERROR']):
164198
if line.strip() not in [e.strip() for e in self.errors]:
165199
self.errors.append(f"[{log_path.name}:{i+1}] {line.strip()}")
166200
except Exception as e:
167201
pass # Ignore parsing errors
168-
202+
203+
def dump_failure_logs(self):
204+
"""Dump log file contents when tests fail - visible in GitHub Actions"""
205+
if not self.log_dir or not Path(self.log_dir).exists():
206+
return
207+
208+
print(f"\n{Colors.RED}{'='*80}{Colors.NC}")
209+
print(f"{Colors.RED}FAILURE LOG DUMP (for GitHub Actions debugging){Colors.NC}")
210+
print(f"{Colors.RED}{'='*80}{Colors.NC}")
211+
212+
log_files = [
213+
'test_control_plane.log',
214+
'test_agents.log',
215+
'test_onprem.log',
216+
'test_mtls_client.log',
217+
'master.log'
218+
]
219+
220+
for log_name in log_files:
221+
log_path = Path(self.log_dir) / log_name
222+
if log_path.exists():
223+
try:
224+
with open(log_path, 'r') as f:
225+
content = f.read()
226+
227+
# Only show logs that have content
228+
if content.strip():
229+
# Check if this log has any errors
230+
has_errors = any(pattern in content for pattern in ['✗', 'FAILED', 'CRITICAL', 'Error:', 'Traceback'])
231+
232+
if has_errors:
233+
print(f"\n{Colors.YELLOW}--- {log_name} (CONTAINS ERRORS) ---{Colors.NC}")
234+
# Show last 100 lines for error logs
235+
lines = content.strip().split('\n')
236+
if len(lines) > 100:
237+
print(f"... (showing last 100 of {len(lines)} lines) ...")
238+
print('\n'.join(lines[-100:]))
239+
else:
240+
print(content)
241+
print(f"{Colors.YELLOW}--- END {log_name} ---{Colors.NC}\n")
242+
except Exception as e:
243+
print(f" Could not read {log_name}: {e}")
244+
245+
print(f"{Colors.RED}{'='*80}{Colors.NC}")
246+
169247
def print_summary(self):
170248
"""Print test run summary"""
171249
duration = (self.end_time - self.start_time).total_seconds()
172-
250+
173251
print()
174252
print(f"{Colors.BOLD}{'='*80}{Colors.NC}")
175253
print(f"{Colors.BOLD}Test Run Summary{Colors.NC}")
176254
print(f"{Colors.BOLD}{'='*80}{Colors.NC}")
177255
print(f"Duration: {duration:.1f} seconds")
178256
print(f"Exit Code: {self.exit_code}")
179-
257+
180258
if self.log_dir:
181259
print(f"Logs: {self.log_dir}")
182-
260+
183261
if self.warnings:
184262
print(f"\n{Colors.YELLOW}Warnings ({len(self.warnings)}):{Colors.NC}")
185263
for warning in self.warnings[:5]: # Show first 5
186264
print(f" • {warning}")
187265
if len(self.warnings) > 5:
188266
print(f" ... and {len(self.warnings) - 5} more")
189-
267+
190268
if self.errors:
191269
print(f"\n{Colors.RED}Errors ({len(self.errors)}):{Colors.NC}")
192270
for error in self.errors[:10]: # Show first 10
193271
print(f" • {error}")
194272
if len(self.errors) > 10:
195273
print(f" ... and {len(self.errors) - 10} more")
196-
274+
197275
print(f"\n{Colors.BOLD}{'='*80}{Colors.NC}")
198276
if self.exit_code == 0:
199277
print(f"{Colors.GREEN}{Colors.BOLD}✓ TESTS PASSED{Colors.NC}")
200278
else:
201279
print(f"{Colors.RED}{Colors.BOLD}✗ TESTS FAILED{Colors.NC}")
202-
if self.log_dir:
203-
print(f"\n{Colors.YELLOW}Check logs for details:{Colors.NC}")
204-
print(f" master.log: {self.log_dir}/master.log")
280+
# Dump failure logs directly to GitHub Actions output (if not already dumped)
281+
# We still dump here to be safe and comprehensive at the end
282+
self.dump_failure_logs()
205283
print(f"{Colors.BOLD}{'='*80}{Colors.NC}")
206-
284+
207285
def run(self):
208286
"""Run the integration tests"""
209287
self.print_header()
210288
self.start_time = datetime.now()
211-
289+
212290
script_path = Path(__file__).parent / 'test_integration.sh'
213291
if not script_path.exists():
214292
print(f"{Colors.RED}Error: test_integration.sh not found at {script_path}{Colors.NC}")
215293
return 1
216-
294+
217295
cmd = [str(script_path)] + self.args
218-
296+
219297
try:
220298
# Run test_integration.sh with real-time output streaming
221299
process = subprocess.Popen(
@@ -225,23 +303,29 @@ def run(self):
225303
universal_newlines=True,
226304
bufsize=1
227305
)
228-
306+
229307
# Stream output line by line
230308
for line in process.stdout:
231309
# Print to stdout for real-time monitoring
232310
print(line, end='')
233-
311+
234312
# Extract log directory
235313
self.extract_log_dir(line)
236-
314+
315+
# Track current log file
316+
self.track_log_file(line)
317+
237318
# Detect errors and warnings
238-
self.detect_error(line)
319+
if self.detect_error(line):
320+
# Immediate failure feedback!
321+
self.dump_current_log_tail()
322+
239323
self.detect_warning(line)
240-
324+
241325
# Wait for completion
242326
process.wait()
243327
self.exit_code = process.returncode
244-
328+
245329
except KeyboardInterrupt:
246330
print(f"\n{Colors.YELLOW}Test interrupted by user{Colors.NC}")
247331
if process:
@@ -251,21 +335,21 @@ def run(self):
251335
except Exception as e:
252336
print(f"{Colors.RED}Error running tests: {e}{Colors.NC}")
253337
self.exit_code = 1
254-
338+
255339
self.end_time = datetime.now()
256-
340+
257341
# Parse log files to extract failure details
258342
if self.exit_code != 0:
259343
self.parse_log_files()
260-
344+
261345
self.print_summary()
262-
346+
263347
return self.exit_code
264348

265349
def main():
266350
"""Main entry point"""
267351
import argparse
268-
352+
269353
parser = argparse.ArgumentParser(
270354
description='CI Test Runner for Unified Identity Integration Tests',
271355
formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -287,22 +371,22 @@ def main():
287371
./ci_test_runner.py -- --no-pause --no-build
288372
"""
289373
)
290-
374+
291375
parser.add_argument('--no-color', action='store_true',
292376
help='Disable color output (for CI)')
293377
parser.add_argument('test_args', nargs='*',
294378
help='Arguments to pass to test_integration.sh (use -- to separate)')
295-
379+
296380
# Parse only known args, let the rest pass through
297381
args, unknown = parser.parse_known_args()
298-
382+
299383
# Combine test_args and unknown args
300384
all_test_args = args.test_args + unknown
301-
385+
302386
# Add --no-pause by default for CI usage (unless already present)
303387
if '--no-pause' not in all_test_args and '--pause' not in ' '.join(all_test_args):
304388
all_test_args.append('--no-pause')
305-
389+
306390
runner = TestRunner(args=all_test_args, no_color=args.no_color)
307391
exit_code = runner.run()
308392
sys.exit(exit_code)

0 commit comments

Comments
 (0)