66
77
88# providers/aws_provider.py
9+ import os
10+ import re
11+ import time
12+
913from kernel_ci_cloud_labs .core .base_provider import BaseProvider
1014from kernel_ci_cloud_labs .core .logging_config import get_logger
1115from kernel_ci_cloud_labs .core .registry import register_provider
1216
1317logger = get_logger (__name__ )
1418
1519
20+ # Kernel-side crash / stall patterns matched against each new CloudWatch
21+ # event from the guest VM console. First hit triggers an early abort of
22+ # wait_for_task_completion so the kernelci-api node is finished
23+ # incomplete/Infrastructure rather than the loop spinning for an hour on a
24+ # wedged guest.
25+ _KERNEL_CRASH_PATTERNS = tuple (
26+ re .compile (p ) for p in (
27+ # Fatal traps -- guest will normally exit, but if qemu wedges it won't.
28+ r"Kernel panic - not syncing" ,
29+ r"\bOops\s*:" ,
30+ r"\bBUG\s*:" ,
31+ r"general protection fault" ,
32+ r"unable to handle kernel paging request" ,
33+ r"double fault" ,
34+ r"Internal error\s*:" , # arm / arm64 die() banner
35+ # Stalls / hangs -- kernel is still scheduling but wedged.
36+ r"watchdog: BUG: soft lockup" ,
37+ r"soft lockup - CPU#" ,
38+ r"rcu_(?:sched|preempt|bh) detected stalls" ,
39+ r"INFO: task .* blocked for more than" ,
40+ )
41+ )
42+
43+
44+ def _scan_for_kernel_crash (events ):
45+ """Return the first event whose message matches a crash pattern, else None."""
46+ for event in events :
47+ message = event .get ("message" ) or ""
48+ for pat in _KERNEL_CRASH_PATTERNS :
49+ if pat .search (message ):
50+ return event
51+ return None
52+
53+
1654@register_provider ("aws" )
1755class AWSProvider (BaseProvider ):
1856 """AWS provider for running containers on Fargate."""
@@ -215,18 +253,68 @@ def wait_for_running(self, timeout=300):
215253 logger .warning ("✗ Task failed to reach RUNNING state: %s" , e )
216254 return False
217255
218- def wait_for_task_completion (self ):
256+ def _build_vm_log_manager (self , start_time_ms ):
257+ """Build an AWSCloudWatchManager scoped to this run's VM console group.
258+
259+ Returns None when no EC2 log group / run prefix is configured (e.g.
260+ unit tests or a deployment without the cloudwatch section); in that
261+ case wait_for_task_completion falls back to pure status polling
262+ without crash detection.
219263 """
220- Wait for the ECS task to complete (reach STOPPED state).
264+ cw_log_groups = self .config .get ("cloudwatch" , {}).get ("log_groups" , {}) or {}
265+ ec2_log_group = next ((k for k in cw_log_groups if "/ec2/" in k ), None )
266+ run_prefix = self .config .get ("run_prefix" )
267+ if not ec2_log_group or not run_prefix :
268+ logger .debug (
269+ "VM crash detection disabled: ec2_log_group=%s run_prefix=%s" ,
270+ ec2_log_group , run_prefix ,
271+ )
272+ return None
273+ try :
274+ logs_client = self .auth .get_client ("logs" )
275+ except Exception as e : # pylint: disable=broad-exception-caught
276+ logger .warning (
277+ "Could not obtain CloudWatch logs client (%s) — "
278+ "VM crash detection disabled" , e ,
279+ )
280+ return None
281+ if not logs_client :
282+ logger .warning ("No CloudWatch logs client — VM crash detection disabled" )
283+ return None
284+ from kernel_ci_cloud_labs .auth .aws_cloudwatch_manager import ( # noqa: PLC0415
285+ AWSCloudWatchManager ,
286+ )
287+ return AWSCloudWatchManager (
288+ logs_client ,
289+ {},
290+ run_prefix = run_prefix ,
291+ start_time_ms = start_time_ms ,
292+ ec2_log_group = ec2_log_group ,
293+ )
221294
222- This uses boto3's tasks_stopped waiter which polls the task status
223- until it transitions to STOPPED, meaning:
224- - All containers have finished executing
225- - All VMs have been spawned, run tests, and uploaded results to S3
226- - The task has gracefully shut down
295+ def wait_for_task_completion (self ):
296+ """Wait for the ECS task to reach STOPPED, with crash / stall detection.
297+
298+ Polls task status until STOPPED. While waiting, tails the per-run VM
299+ console log group ({EC2_LOG_GROUP}/{run_prefix} -- written by SSM Run
300+ Command, see launch_vm.py) and aborts early on:
301+
302+ * Kernel-side crash patterns in the guest console (panic, Oops, BUG:,
303+ soft lockup, RCU stall, hung task, GP fault, kernel paging fault).
304+ The ECS task is stopped and a RuntimeError is raised so the caller
305+ finishes the kernelci-api node incomplete/Infrastructure with the
306+ matched line surfaced in error_msg.
307+ * No new VM console output for PULLAB_TASK_HANG_THRESHOLD_SEC seconds
308+ (default 600) -- silent stall, same treatment as a crash.
309+ * Overall PULLAB_TASK_WAIT_TIMEOUT_SEC seconds elapsed (default 3600)
310+ -- final safety net for whatever isn't covered above.
311+
312+ Crash detection requires both cloudwatch.log_groups (with an /ec2/
313+ group) and run_prefix in the run config; otherwise the loop falls
314+ back to plain status polling.
227315
228316 Returns:
229- dict: Final task status including exit codes
317+ dict: Final task status including exit codes.
230318 """
231319 if not self .task_arn :
232320 logger .error ("Cannot wait for completion - no task ARN available" )
@@ -235,39 +323,90 @@ def wait_for_task_completion(self):
235323 logger .info ("Waiting for task to complete..." )
236324 logger .debug ("Task ARN: %s" , self .task_arn )
237325
238- # Poll task status with periodic INFO logging so the user sees progress
239- import time as _time
326+ poll_interval = float (os .getenv ("PULLAB_TASK_POLL_INTERVAL_SEC" ) or 30 )
327+ log_interval = float (os .getenv ("PULLAB_TASK_PROGRESS_LOG_SEC" ) or 120 )
328+ hang_threshold = float (os .getenv ("PULLAB_TASK_HANG_THRESHOLD_SEC" ) or 600 )
329+ overall_timeout = float (os .getenv ("PULLAB_TASK_WAIT_TIMEOUT_SEC" ) or 3600 )
240330
241- poll_interval = 30 # seconds between status checks
242- log_interval = 120 # seconds between INFO progress messages
243- start = _time .time ()
331+ start = time .time ()
332+ start_ms = int (start * 1000 )
244333 last_log_time = start
334+ last_event_seen_at = start
335+ # filter_log_events startTime is inclusive; -1 so the first poll
336+ # picks up events with timestamp == start_ms.
337+ last_event_ms = start_ms - 1
338+
339+ cw_manager = self ._build_vm_log_manager (start_ms )
340+ if cw_manager is None :
341+ logger .info ("Wait loop: VM crash detection disabled (no log group / run_prefix)" )
245342
246343 while True :
344+ elapsed = time .time () - start
345+ if elapsed > overall_timeout :
346+ logger .error (
347+ "Overall wait timeout (%ds) exceeded — stopping task" ,
348+ int (overall_timeout ),
349+ )
350+ self .terminate_container ()
351+ raise RuntimeError (
352+ f"task wait timeout exceeded after { int (elapsed )} s"
353+ )
354+
247355 status = self .get_task_status ()
248356 if not status :
249357 logger .warning ("Could not retrieve task status, retrying..." )
250- _time .sleep (poll_interval )
358+ time .sleep (poll_interval )
251359 continue
252360
253361 task_status = status .get ("status" , "UNKNOWN" )
254- elapsed = int (_time .time () - start )
255362
256363 if task_status == "STOPPED" :
257- logger .info ("✓ Task completed (elapsed: %dm %ds)" , elapsed // 60 , elapsed % 60 )
364+ logger .info (
365+ "✓ Task completed (elapsed: %dm %ds)" ,
366+ int (elapsed ) // 60 , int (elapsed ) % 60 ,
367+ )
258368 break
259369
260- now = _time .time ()
370+ # Tail the VM console group for crash patterns / progress.
371+ if cw_manager is not None :
372+ new_events = cw_manager .get_logs_with_filter (
373+ start_time = last_event_ms + 1
374+ ) or []
375+ if new_events :
376+ last_event_seen_at = time .time ()
377+ for ev in new_events :
378+ ts = ev .get ("timestamp" , 0 )
379+ if ts > last_event_ms :
380+ last_event_ms = ts
381+ hit = _scan_for_kernel_crash (new_events )
382+ if hit :
383+ msg = (hit .get ("message" ) or "" ).strip ()[:300 ]
384+ logger .error (
385+ "Kernel crash/stall in VM console (stream=%s): %s" ,
386+ hit .get ("logStreamName" , "?" ), msg ,
387+ )
388+ self .terminate_container ()
389+ raise RuntimeError (f"kernel crash detected in VM: { msg } " )
390+ elif (time .time () - last_event_seen_at ) > hang_threshold :
391+ logger .error (
392+ "No VM console output for %ds (hang threshold %ds) — stopping task" ,
393+ int (time .time () - last_event_seen_at ),
394+ int (hang_threshold ),
395+ )
396+ self .terminate_container ()
397+ raise RuntimeError (
398+ f"no VM console output for { int (hang_threshold )} s"
399+ )
400+
401+ now = time .time ()
261402 if now - last_log_time >= log_interval :
262403 last_log_time = now
263404 logger .info (
264405 "Task still running... (status: %s, elapsed: %dm %ds)" ,
265- task_status ,
266- elapsed // 60 ,
267- elapsed % 60 ,
406+ task_status , int (elapsed ) // 60 , int (elapsed ) % 60 ,
268407 )
269408
270- _time .sleep (poll_interval )
409+ time .sleep (poll_interval )
271410
272411 # Get final status to check exit codes
273412 final_status = self .get_task_status ()
0 commit comments