8
8
resetRunnersCaches ,
9
9
terminateRunner ,
10
10
tryReuseRunner ,
11
+ terminateRunners ,
11
12
} from './runners' ;
12
13
import { RunnerInfo } from './utils' ;
13
14
import { ScaleUpMetrics } from './metrics' ;
@@ -326,10 +327,11 @@ describe('listSSMParameters', () => {
326
327
} ) ;
327
328
} ) ;
328
329
329
- describe ( 'terminateRunner ' , ( ) => {
330
+ describe ( 'terminateRunners ' , ( ) => {
330
331
beforeEach ( ( ) => {
331
332
mockSSMdescribeParametersRet . mockClear ( ) ;
332
333
mockEC2 . terminateInstances . mockClear ( ) ;
334
+ mockSSM . deleteParameter . mockClear ( ) ;
333
335
const config = {
334
336
environment : 'gi-ci' ,
335
337
minimumRunningTimeInMinutes : 45 ,
@@ -339,66 +341,195 @@ describe('terminateRunner', () => {
339
341
resetRunnersCaches ( ) ;
340
342
} ) ;
341
343
342
- it ( 'calls terminateInstances' , async ( ) => {
343
- const runner : RunnerInfo = {
344
- awsRegion : Config . Instance . awsRegion ,
345
- instanceId : 'i-1234' ,
346
- environment : 'gi-ci' ,
347
- } ;
344
+ it ( 'terminates multiple runners in same region successfully' , async ( ) => {
345
+ const runners : RunnerInfo [ ] = [
346
+ {
347
+ awsRegion : 'us-east-1' ,
348
+ instanceId : 'i-1234' ,
349
+ environment : 'gi-ci' ,
350
+ } ,
351
+ {
352
+ awsRegion : 'us-east-1' ,
353
+ instanceId : 'i-5678' ,
354
+ environment : 'gi-ci' ,
355
+ } ,
356
+ ] ;
357
+
348
358
mockSSMdescribeParametersRet . mockResolvedValueOnce ( {
349
- Parameters : [ getParameterNameForRunner ( runner . environment as string , runner . instanceId ) ] . map ( ( s ) => {
350
- return { Name : s } ;
351
- } ) ,
359
+ Parameters : runners
360
+ . map ( ( runner ) => getParameterNameForRunner ( runner . environment as string , runner . instanceId ) )
361
+ . map ( ( s ) => ( { Name : s } ) ) ,
352
362
} ) ;
353
- await terminateRunner ( runner , metrics ) ;
354
363
364
+ await terminateRunners ( runners , metrics ) ;
365
+
366
+ expect ( mockEC2 . terminateInstances ) . toBeCalledTimes ( 1 ) ;
355
367
expect ( mockEC2 . terminateInstances ) . toBeCalledWith ( {
356
- InstanceIds : [ runner . instanceId ] ,
368
+ InstanceIds : [ 'i-1234' , 'i-5678' ] ,
357
369
} ) ;
358
370
expect ( mockSSM . describeParameters ) . toBeCalledTimes ( 1 ) ;
359
- expect ( mockSSM . deleteParameter ) . toBeCalledTimes ( 1 ) ;
360
- expect ( mockSSM . deleteParameter ) . toBeCalledWith ( {
361
- Name : getParameterNameForRunner ( runner . environment as string , runner . instanceId ) ,
371
+ expect ( mockSSM . deleteParameter ) . toBeCalledTimes ( 2 ) ;
372
+ } ) ;
373
+
374
+ it ( 'terminates runners across multiple regions' , async ( ) => {
375
+ const runners : RunnerInfo [ ] = [
376
+ {
377
+ awsRegion : 'us-east-1' ,
378
+ instanceId : 'i-1234' ,
379
+ environment : 'gi-ci' ,
380
+ } ,
381
+ {
382
+ awsRegion : 'us-west-2' ,
383
+ instanceId : 'i-5678' ,
384
+ environment : 'gi-ci' ,
385
+ } ,
386
+ ] ;
387
+
388
+ mockSSMdescribeParametersRet . mockResolvedValue ( {
389
+ Parameters : [ { Name : 'gi-ci-i-1234' } , { Name : 'gi-ci-i-5678' } ] ,
390
+ } ) ;
391
+
392
+ await terminateRunners ( runners , metrics ) ;
393
+
394
+ expect ( mockEC2 . terminateInstances ) . toBeCalledTimes ( 2 ) ;
395
+ expect ( mockEC2 . terminateInstances ) . toHaveBeenNthCalledWith ( 1 , {
396
+ InstanceIds : [ 'i-1234' ] ,
362
397
} ) ;
398
+ expect ( mockEC2 . terminateInstances ) . toHaveBeenNthCalledWith ( 2 , {
399
+ InstanceIds : [ 'i-5678' ] ,
400
+ } ) ;
401
+ expect ( mockSSM . describeParameters ) . toBeCalledTimes ( 2 ) ;
402
+ expect ( mockSSM . deleteParameter ) . toBeCalledTimes ( 2 ) ;
363
403
} ) ;
364
404
365
- it ( 'fails to terminate' , async ( ) => {
366
- const errMsg = 'Error message' ;
367
- const runner : RunnerInfo = {
368
- awsRegion : Config . Instance . awsRegion ,
369
- instanceId : '1234' ,
370
- } ;
371
- mockEC2 . terminateInstances . mockClear ( ) . mockReturnValue ( {
372
- promise : jest . fn ( ) . mockRejectedValueOnce ( Error ( errMsg ) ) ,
405
+ it ( 'handles partial failure - terminates some runners but fails on others' , async ( ) => {
406
+ const runners : RunnerInfo [ ] = [
407
+ {
408
+ awsRegion : 'us-east-1' ,
409
+ instanceId : 'i-1234' ,
410
+ environment : 'gi-ci' ,
411
+ } ,
412
+ {
413
+ awsRegion : 'us-east-1' ,
414
+ instanceId : 'i-5678' ,
415
+ environment : 'gi-ci' ,
416
+ } ,
417
+ {
418
+ awsRegion : 'us-west-2' ,
419
+ instanceId : 'i-9999' ,
420
+ environment : 'gi-ci' ,
421
+ } ,
422
+ ] ;
423
+
424
+ // First region succeeds
425
+ mockSSMdescribeParametersRet . mockResolvedValueOnce ( {
426
+ Parameters : [ { Name : 'gi-ci-i-1234' } , { Name : 'gi-ci-i-5678' } ] ,
373
427
} ) ;
374
- expect ( terminateRunner ( runner , metrics ) ) . rejects . toThrowError ( errMsg ) ;
375
- expect ( mockSSM . describeParameters ) . not . toBeCalled ( ) ;
376
- expect ( mockSSM . deleteParameter ) . not . toBeCalled ( ) ;
428
+
429
+ // Second region also gets SSM parameters but has no successful terminations to clean up
430
+ mockSSMdescribeParametersRet . mockResolvedValueOnce ( {
431
+ Parameters : [ ] ,
432
+ } ) ;
433
+
434
+ // First region succeeds, second region fails
435
+ mockEC2 . terminateInstances
436
+ . mockReturnValueOnce ( {
437
+ promise : jest . fn ( ) . mockResolvedValueOnce ( { } ) ,
438
+ } )
439
+ . mockReturnValueOnce ( {
440
+ promise : jest . fn ( ) . mockRejectedValueOnce ( new Error ( 'Region failure' ) ) ,
441
+ } ) ;
442
+
443
+ await expect ( terminateRunners ( runners , metrics ) ) . rejects . toThrow (
444
+ 'Failed to terminate some runners: Instance i-9999: Region failure' ,
445
+ ) ;
446
+
447
+ expect ( mockEC2 . terminateInstances ) . toBeCalledTimes ( 2 ) ;
448
+ expect ( mockSSM . describeParameters ) . toBeCalledTimes ( 2 ) ; // Called for both regions
449
+ expect ( mockSSM . deleteParameter ) . toBeCalledTimes ( 2 ) ; // Only for successful region
377
450
} ) ;
378
451
379
- it ( 'fails to list parameters on terminate, then force delete all next parameters' , async ( ) => {
380
- const runner1 : RunnerInfo = {
381
- awsRegion : Config . Instance . awsRegion ,
382
- instanceId : '1234' ,
383
- environment : 'environ' ,
384
- } ;
385
- const runner2 : RunnerInfo = {
386
- awsRegion : Config . Instance . awsRegion ,
387
- instanceId : '1235' ,
388
- environment : 'environ' ,
389
- } ;
390
- mockSSMdescribeParametersRet . mockRejectedValueOnce ( 'Some Error' ) ;
391
- await terminateRunner ( runner1 , metrics ) ;
392
- await terminateRunner ( runner2 , metrics ) ;
452
+ it ( 'handles large batches by splitting into chunks' , async ( ) => {
453
+ // Create 150 runners to test batching (should split into 2 batches of 100 and 50)
454
+ const runners : RunnerInfo [ ] = Array . from ( { length : 150 } , ( _ , i ) => ( {
455
+ awsRegion : 'us-east-1' ,
456
+ instanceId : `i-${ i . toString ( ) . padStart ( 4 , '0' ) } ` ,
457
+ environment : 'gi-ci' ,
458
+ } ) ) ;
393
459
460
+ mockSSMdescribeParametersRet . mockResolvedValueOnce ( {
461
+ Parameters : runners . map ( ( runner ) => ( {
462
+ Name : getParameterNameForRunner ( runner . environment as string , runner . instanceId ) ,
463
+ } ) ) ,
464
+ } ) ;
465
+
466
+ await terminateRunners ( runners , metrics ) ;
467
+
468
+ // Should make 2 terminate calls (batches of 100 and 50)
394
469
expect ( mockEC2 . terminateInstances ) . toBeCalledTimes ( 2 ) ;
470
+ expect ( mockEC2 . terminateInstances ) . toHaveBeenNthCalledWith ( 1 , {
471
+ InstanceIds : runners . slice ( 0 , 100 ) . map ( ( r ) => r . instanceId ) ,
472
+ } ) ;
473
+ expect ( mockEC2 . terminateInstances ) . toHaveBeenNthCalledWith ( 2 , {
474
+ InstanceIds : runners . slice ( 100 , 150 ) . map ( ( r ) => r . instanceId ) ,
475
+ } ) ;
476
+
477
+ // SSM cleanup should handle all 150 parameters
395
478
expect ( mockSSM . describeParameters ) . toBeCalledTimes ( 1 ) ;
396
- expect ( mockSSM . deleteParameter ) . toBeCalledTimes ( 2 ) ;
397
- expect ( mockSSM . deleteParameter ) . toBeCalledWith ( {
398
- Name : getParameterNameForRunner ( runner1 . environment as string , runner1 . instanceId ) ,
479
+ expect ( mockSSM . deleteParameter ) . toBeCalledTimes ( 150 ) ;
480
+ } ) ;
481
+
482
+ it ( 'cleans up SSM parameters for successful batches even when later batch fails' , async ( ) => {
483
+ // Create runners that will be split into 2 batches
484
+ const runners : RunnerInfo [ ] = Array . from ( { length : 150 } , ( _ , i ) => ( {
485
+ awsRegion : 'us-east-1' ,
486
+ instanceId : `i-${ i . toString ( ) . padStart ( 4 , '0' ) } ` ,
487
+ environment : 'gi-ci' ,
488
+ } ) ) ;
489
+
490
+ mockSSMdescribeParametersRet . mockResolvedValueOnce ( {
491
+ Parameters : runners . slice ( 0 , 100 ) . map ( ( runner ) => ( {
492
+ Name : getParameterNameForRunner ( runner . environment as string , runner . instanceId ) ,
493
+ } ) ) ,
399
494
} ) ;
495
+
496
+ // First batch succeeds, second batch fails
497
+ mockEC2 . terminateInstances
498
+ . mockReturnValueOnce ( {
499
+ promise : jest . fn ( ) . mockResolvedValueOnce ( { } ) ,
500
+ } )
501
+ . mockReturnValueOnce ( {
502
+ promise : jest . fn ( ) . mockRejectedValueOnce ( new Error ( 'Batch 2 failed' ) ) ,
503
+ } ) ;
504
+
505
+ await expect ( terminateRunners ( runners , metrics ) ) . rejects . toThrow ( 'Failed to terminate some runners' ) ;
506
+
507
+ expect ( mockEC2 . terminateInstances ) . toBeCalledTimes ( 2 ) ;
508
+ // SSM cleanup should still happen for the first 100 runners that were successfully terminated
509
+ expect ( mockSSM . describeParameters ) . toBeCalledTimes ( 1 ) ;
510
+ expect ( mockSSM . deleteParameter ) . toBeCalledTimes ( 100 ) ;
511
+ } ) ;
512
+
513
+ it ( 'handles SSM parameter cleanup failure gracefully' , async ( ) => {
514
+ const runners : RunnerInfo [ ] = [
515
+ {
516
+ awsRegion : 'us-east-1' ,
517
+ instanceId : 'i-1234' ,
518
+ environment : 'gi-ci' ,
519
+ } ,
520
+ ] ;
521
+
522
+ // SSM describe fails, so it should attempt direct deletion
523
+ mockSSMdescribeParametersRet . mockRejectedValueOnce ( new Error ( 'SSM describe failed' ) ) ;
524
+
525
+ await terminateRunners ( runners , metrics ) ;
526
+
527
+ expect ( mockEC2 . terminateInstances ) . toBeCalledTimes ( 1 ) ;
528
+ expect ( mockSSM . describeParameters ) . toBeCalledTimes ( 1 ) ;
529
+ // Should still attempt direct deletion even when describe fails
530
+ expect ( mockSSM . deleteParameter ) . toBeCalledTimes ( 1 ) ;
400
531
expect ( mockSSM . deleteParameter ) . toBeCalledWith ( {
401
- Name : getParameterNameForRunner ( runner2 . environment as string , runner2 . instanceId ) ,
532
+ Name : getParameterNameForRunner ( runners [ 0 ] . environment as string , runners [ 0 ] . instanceId ) ,
402
533
} ) ;
403
534
} ) ;
404
535
} ) ;
@@ -1625,3 +1756,44 @@ describe('createRunner', () => {
1625
1756
} ) ;
1626
1757
} ) ;
1627
1758
} ) ;
1759
+
1760
+ describe ( 'terminateRunner' , ( ) => {
1761
+ beforeEach ( ( ) => {
1762
+ mockSSMdescribeParametersRet . mockClear ( ) ;
1763
+ mockEC2 . terminateInstances . mockClear ( ) ;
1764
+ mockSSM . deleteParameter . mockClear ( ) ;
1765
+ const config = {
1766
+ environment : 'gi-ci' ,
1767
+ minimumRunningTimeInMinutes : 45 ,
1768
+ } ;
1769
+ jest . spyOn ( Config , 'Instance' , 'get' ) . mockImplementation ( ( ) => config as unknown as Config ) ;
1770
+
1771
+ resetRunnersCaches ( ) ;
1772
+ } ) ;
1773
+
1774
+ it ( 'delegates to terminateRunners with single runner array' , async ( ) => {
1775
+ const runner : RunnerInfo = {
1776
+ awsRegion : 'us-east-1' ,
1777
+ instanceId : 'i-1234' ,
1778
+ environment : 'gi-ci' ,
1779
+ } ;
1780
+
1781
+ // Mock terminateRunners by mocking the underlying calls
1782
+ mockSSMdescribeParametersRet . mockResolvedValueOnce ( {
1783
+ Parameters : [ { Name : 'gi-ci-i-1234' } ] ,
1784
+ } ) ;
1785
+ mockEC2 . terminateInstances . mockReturnValueOnce ( {
1786
+ promise : jest . fn ( ) . mockResolvedValueOnce ( { } ) ,
1787
+ } ) ;
1788
+
1789
+ await terminateRunner ( runner , metrics ) ;
1790
+
1791
+ // Verify the calls match what terminateRunners would do with a single runner
1792
+ expect ( mockEC2 . terminateInstances ) . toBeCalledWith ( {
1793
+ InstanceIds : [ 'i-1234' ] ,
1794
+ } ) ;
1795
+ expect ( mockSSM . deleteParameter ) . toBeCalledWith ( {
1796
+ Name : 'gi-ci-i-1234' ,
1797
+ } ) ;
1798
+ } ) ;
1799
+ } ) ;
0 commit comments