Skip to content

Commit aad2f23

Browse files
committed
Wait for logs after vm termination
Signed-off-by: Denys Fedoryshchenko <denys.f@collabora.com>
1 parent ae3b2ff commit aad2f23

1 file changed

Lines changed: 122 additions & 15 deletions

File tree

src/kernel_ci_cloud_labs/launch_vm.py

Lines changed: 122 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,35 @@ def log_not(msg): # pylint: disable=unused-argument
3535
# Info messages only appear in VM CloudWatch logs, not container log
3636

3737

38+
# Kernel-side fatal/near-fatal markers we scan captured console buffers for.
39+
# Hit on any of these gets logged loudly and stamped into the S3 object
40+
# metadata so downstream consumers (KCIDB submitter, triage tooling) can
41+
# flag the run without re-parsing the log. Patterns are case-sensitive
42+
# substrings — kernel prints these verbatim.
43+
PANIC_PATTERNS = (
44+
"Kernel panic - not syncing",
45+
"Oops: ",
46+
"BUG: ",
47+
"Call Trace:",
48+
"general protection fault",
49+
"Unable to handle kernel",
50+
"double fault",
51+
"watchdog: BUG: soft lockup",
52+
"rcu_sched detected stalls",
53+
"page fault in interrupt",
54+
)
55+
56+
57+
def _scan_for_panic(text):
58+
"""Return the first PANIC_PATTERNS substring found in text, or None."""
59+
if not text:
60+
return None
61+
for pattern in PANIC_PATTERNS:
62+
if pattern in text:
63+
return pattern
64+
return None
65+
66+
3867
class VMLauncher:
3968
"""Launch and manage EC2 instances with multi-run test support."""
4069

@@ -365,16 +394,23 @@ def check_test_result(self):
365394
def capture_console_output(self, reason="cleanup"):
366395
"""Fetch EC2 serial console output (kernel boot log) and upload to S3.
367396
368-
Safe to call multiple times: subsequent calls will overwrite the S3
369-
object with a (typically larger) snapshot. Once we have successfully
370-
captured a non-empty buffer, later calls are skipped — except when
371-
forced via a different `reason` from the SSM-failure path, where the
372-
most recent state of the buffer is more interesting than an earlier
373-
capture taken before the failure was visible.
397+
Re-entrancy rules:
398+
* "cleanup" is the best-effort pre-terminate pass. It's skipped if
399+
a previous call already uploaded — there's nothing new to fetch
400+
while the VM is still running and the buffer only grows.
401+
* "ssm-failure" and "post-terminate" always run. The first catches
402+
a panic visible mid-run; the second catches the flushed final
403+
buffer that EC2 only finalizes after shutdown — typically the
404+
only place an early-boot panic actually shows up. Both can
405+
overwrite an earlier (smaller) capture.
406+
407+
Also scans the scrubbed output for PANIC_PATTERNS and stamps the
408+
result into the S3 object metadata so triage tooling can flag a run
409+
without re-reading the log.
374410
375411
Args:
376412
reason: Free-text label for the call site (logged for diagnostics).
377-
Currently used values: "cleanup", "ssm-failure".
413+
Currently used values: "cleanup", "ssm-failure", "post-terminate".
378414
"""
379415
if not self.instance_id:
380416
return
@@ -411,6 +447,15 @@ def capture_console_output(self, reason="cleanup"):
411447
log_not(f" Console scrub redacted: {summary}")
412448
output = scrubbed
413449

450+
# Panic scan runs on the scrubbed buffer so the pattern we log can't
451+
# re-leak a token the scrubber just redacted.
452+
panic_match = _scan_for_panic(output)
453+
if panic_match:
454+
log_error(
455+
f"⚠ Kernel panic indicator in console buffer "
456+
f"(instance={self.instance_id}, marker={panic_match!r}, reason={reason})"
457+
)
458+
414459
s3_key = f"{self.run_prefix}/test_{self.test}/output/{self.instance_id}/console-output.log"
415460
try:
416461
self.s3.put_object(
@@ -423,24 +468,86 @@ def capture_console_output(self, reason="cleanup"):
423468
# Records that the buffer passed through the scrubber, so an
424469
# operator inspecting the object knows it's not raw.
425470
"scrubbed": "v1",
471+
"panic-detected": "true" if panic_match else "false",
426472
},
427473
)
428474
log_not(f"✓ Console output uploaded ({len(output)} bytes) to s3://{self.s3_bucket}/{s3_key}")
429475
self._console_captured = True
430476
except Exception as e:
431477
log_not(f" Failed to upload console output: {e}")
432478

433-
def cleanup(self):
434-
"""Capture console output, then terminate instance."""
435-
self.capture_console_output(reason="cleanup")
479+
def _wait_for_terminated(self, timeout=90):
480+
"""Poll describe_instances until the VM reaches a terminal state.
436481
437-
if self.instance_id:
438-
log_not(f"\n=== Terminating instance {self.instance_id} ===")
482+
EC2 only finalizes the serial-console buffer at shutdown; a
483+
get_console_output call against a still-running short-lived VM
484+
often returns empty because the async mirror hasn't caught up. By
485+
waiting for ``terminated``/``stopped`` we can re-fetch and get the
486+
buffer that includes early-boot output and any panic on shutdown.
487+
488+
Bounded by ``timeout`` so a stuck instance can't pin the pipeline.
489+
Returns True if a terminal state was observed, False on timeout or
490+
API error (caller proceeds either way).
491+
"""
492+
if not self.instance_id:
493+
return False
494+
log_not(f"Waiting up to {timeout}s for instance {self.instance_id} to reach terminated/stopped state...")
495+
start = time.time()
496+
while time.time() - start < timeout:
439497
try:
440-
self.ec2.terminate_instances(InstanceIds=[self.instance_id])
441-
log_not("✓ Instance terminated")
498+
resp = self.ec2.describe_instances(InstanceIds=[self.instance_id])
499+
reservations = resp.get("Reservations", [])
500+
if not reservations or not reservations[0].get("Instances"):
501+
# Instance metadata aged out — treat as terminal.
502+
return True
503+
state = reservations[0]["Instances"][0]["State"]["Name"]
504+
if state in ("terminated", "stopped"):
505+
log_not(f"✓ Instance state: {state} (after {int(time.time() - start)}s)")
506+
return True
442507
except Exception as e:
443-
log_not(f"Error terminating instance: {e}")
508+
log_not(f" describe_instances error: {e} (continuing without wait)")
509+
return False
510+
time.sleep(5)
511+
log_not(f"⚠ Timed out waiting for instance to terminate after {timeout}s")
512+
return False
513+
514+
def cleanup(self):
515+
"""Hybrid console capture around instance termination.
516+
517+
Two captures bracket the terminate call:
518+
519+
1. Pre-terminate (``reason="cleanup"``) — best-effort while the VM
520+
is still alive. Often empty for short-lived VMs (EC2's mirror
521+
lags by minutes), but on a longer run this is the only way to
522+
grab the buffer if termination later hangs.
523+
2. Post-terminate (``reason="post-terminate"``) — after shutdown
524+
EC2 finalizes and preserves the buffer for ~1h. This is the
525+
pass that reliably catches the boot log, kernel panics, and
526+
any shutdown-time oops.
527+
528+
The capture path scans for panic markers and stamps metadata on
529+
whichever upload wins (post-terminate overwrites cleanup).
530+
"""
531+
# Best-effort live capture. Skipped silently inside the helper if a
532+
# prior ssm-failure capture already uploaded.
533+
self.capture_console_output(reason="cleanup")
534+
535+
if not self.instance_id:
536+
return
537+
538+
log_not(f"\n=== Terminating instance {self.instance_id} ===")
539+
try:
540+
self.ec2.terminate_instances(InstanceIds=[self.instance_id])
541+
log_not("✓ Termination requested")
542+
except Exception as e:
543+
log_not(f"Error terminating instance: {e}")
544+
return
545+
546+
# Wait for the VM to actually wind down, then grab the flushed
547+
# buffer. This is where an early-boot panic that never made it into
548+
# the live mirror finally becomes visible.
549+
self._wait_for_terminated(timeout=90)
550+
self.capture_console_output(reason="post-terminate")
444551

445552

446553
def launch_vms_from_config():

0 commit comments

Comments
 (0)