Skip to content

Commit 6b236ac

Browse files
authored
Merge pull request #163 from kddejong/fix/timeout
fix: improve timeout handling for long-running CloudFormation stacks
2 parents 09271ea + 9022452 commit 6b236ac

4 files changed

Lines changed: 301 additions & 27 deletions

File tree

__tests__/deploy.test.ts

Lines changed: 204 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,27 @@ import {
44
DescribeChangeSetCommand,
55
DescribeEventsCommand,
66
CreateChangeSetCommand,
7+
ExecuteChangeSetCommand,
78
StackStatus,
8-
ChangeSetStatus
9+
ChangeSetStatus,
10+
Stack
911
} from '@aws-sdk/client-cloudformation'
1012
import { mockClient } from 'aws-sdk-client-mock'
11-
import { waitUntilStackOperationComplete, updateStack } from '../src/deploy'
13+
import {
14+
waitUntilStackOperationComplete,
15+
updateStack,
16+
executeExistingChangeSet
17+
} from '../src/deploy'
1218
import * as core from '@actions/core'
1319

14-
jest.mock('@actions/core')
20+
jest.mock('@actions/core', () => ({
21+
...jest.requireActual('@actions/core'),
22+
info: jest.fn(),
23+
warning: jest.fn(),
24+
setOutput: jest.fn(),
25+
setFailed: jest.fn(),
26+
debug: jest.fn()
27+
}))
1528

1629
const mockCfnClient = mockClient(CloudFormationClient)
1730
const cfn = new CloudFormationClient({ region: 'us-east-1' })
@@ -25,6 +38,7 @@ describe('Deploy error scenarios', () => {
2538

2639
afterEach(() => {
2740
jest.useRealTimers()
41+
jest.restoreAllMocks()
2842
})
2943

3044
describe('waitUntilStackOperationComplete', () => {
@@ -363,4 +377,191 @@ describe('Deploy error scenarios', () => {
363377
)
364378
})
365379
})
380+
381+
describe('Timeout handling', () => {
382+
it('should timeout after maxWaitTime', async () => {
383+
const realDateNow = Date.now
384+
const realSetTimeout = global.setTimeout
385+
let mockTime = 1000000
386+
Date.now = jest.fn(() => mockTime)
387+
// Mock setTimeout to resolve immediately
388+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
389+
;(global.setTimeout as any) = jest.fn((cb: () => void) => {
390+
cb()
391+
return 0 as unknown as NodeJS.Timeout
392+
})
393+
394+
mockCfnClient.on(DescribeStacksCommand).callsFake(() => {
395+
// Advance mock time by 2 seconds each call
396+
mockTime += 2000
397+
return {
398+
Stacks: [
399+
{
400+
StackName: 'TestStack',
401+
StackStatus: StackStatus.CREATE_IN_PROGRESS,
402+
CreationTime: new Date()
403+
}
404+
]
405+
}
406+
})
407+
408+
await expect(
409+
waitUntilStackOperationComplete(
410+
{ client: cfn, maxWaitTime: 1, minDelay: 0 },
411+
{ StackName: 'TestStack' }
412+
)
413+
).rejects.toThrow('Timeout after 1 seconds')
414+
415+
Date.now = realDateNow
416+
global.setTimeout = realSetTimeout
417+
})
418+
419+
it('should handle timeout gracefully in executeExistingChangeSet', async () => {
420+
const realDateNow = Date.now
421+
const realSetTimeout = global.setTimeout
422+
let mockTime = 1000000
423+
Date.now = jest.fn(() => mockTime)
424+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
425+
;(global.setTimeout as any) = jest.fn((cb: () => void) => {
426+
cb()
427+
return 0 as unknown as NodeJS.Timeout
428+
})
429+
430+
mockCfnClient
431+
.on(ExecuteChangeSetCommand)
432+
.resolves({})
433+
.on(DescribeStacksCommand)
434+
.callsFake(() => {
435+
mockTime += 2000
436+
return {
437+
Stacks: [
438+
{
439+
StackName: 'TestStack',
440+
StackId: 'test-stack-id',
441+
StackStatus: StackStatus.UPDATE_IN_PROGRESS,
442+
CreationTime: new Date()
443+
}
444+
]
445+
}
446+
})
447+
448+
const result = await executeExistingChangeSet(
449+
cfn,
450+
'TestStack',
451+
'test-cs-id',
452+
1 // 1 second timeout
453+
)
454+
455+
expect(core.warning).toHaveBeenCalledWith(
456+
expect.stringContaining('Stack operation exceeded')
457+
)
458+
expect(core.warning).toHaveBeenCalledWith(
459+
expect.stringContaining('TestStack')
460+
)
461+
expect(result).toBe('test-stack-id')
462+
463+
Date.now = realDateNow
464+
global.setTimeout = realSetTimeout
465+
})
466+
467+
it('should handle timeout gracefully in updateStack', async () => {
468+
const realDateNow = Date.now
469+
const realSetTimeout = global.setTimeout
470+
let mockTime = 1000000
471+
Date.now = jest.fn(() => mockTime)
472+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
473+
;(global.setTimeout as any) = jest.fn((cb: () => void) => {
474+
cb()
475+
return 0 as unknown as NodeJS.Timeout
476+
})
477+
478+
mockCfnClient
479+
.on(CreateChangeSetCommand)
480+
.resolves({ Id: 'test-cs-id' })
481+
.on(DescribeChangeSetCommand)
482+
.resolves({
483+
Status: ChangeSetStatus.CREATE_COMPLETE,
484+
Changes: []
485+
})
486+
.on(ExecuteChangeSetCommand)
487+
.resolves({})
488+
.on(DescribeStacksCommand)
489+
.callsFake(() => {
490+
mockTime += 2000
491+
return {
492+
Stacks: [
493+
{
494+
StackName: 'TestStack',
495+
StackId: 'test-stack-id',
496+
StackStatus: StackStatus.UPDATE_IN_PROGRESS,
497+
CreationTime: new Date()
498+
}
499+
]
500+
}
501+
})
502+
503+
const result = await updateStack(
504+
cfn,
505+
{ StackId: 'test-stack-id', StackName: 'TestStack' } as Stack,
506+
{
507+
StackName: 'TestStack',
508+
ChangeSetName: 'test-cs',
509+
ChangeSetType: 'UPDATE'
510+
},
511+
false,
512+
false, // Execute the change set
513+
false,
514+
1 // 1 second timeout
515+
)
516+
517+
expect(core.warning).toHaveBeenCalledWith(
518+
expect.stringContaining('Stack operation exceeded')
519+
)
520+
expect(core.warning).toHaveBeenCalledWith(
521+
expect.stringContaining('TestStack')
522+
)
523+
expect(result.stackId).toBe('test-stack-id')
524+
525+
Date.now = realDateNow
526+
global.setTimeout = realSetTimeout
527+
})
528+
529+
it('should accept custom maxWaitTime parameter', async () => {
530+
mockCfnClient
531+
.on(CreateChangeSetCommand)
532+
.resolves({ Id: 'test-cs-id' })
533+
.on(DescribeChangeSetCommand)
534+
.resolves({
535+
Status: ChangeSetStatus.CREATE_COMPLETE,
536+
Changes: []
537+
})
538+
.on(DescribeStacksCommand)
539+
.resolves({
540+
Stacks: [
541+
{
542+
StackName: 'TestStack',
543+
StackId: 'test-stack-id',
544+
StackStatus: StackStatus.CREATE_COMPLETE,
545+
CreationTime: new Date()
546+
}
547+
]
548+
})
549+
550+
const result = await updateStack(
551+
cfn,
552+
{ StackId: 'test-stack-id', StackName: 'TestStack' } as Stack,
553+
{
554+
StackName: 'TestStack',
555+
ChangeSetName: 'test-cs',
556+
ChangeSetType: 'UPDATE'
557+
},
558+
false,
559+
true, // noExecuteChangeSet - skip execution
560+
false,
561+
300 // Custom maxWaitTime
562+
)
563+
564+
expect(result.stackId).toBe('test-stack-id')
565+
})
566+
})
366567
})

dist/index.js

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50444,15 +50444,26 @@ function waitUntilStackOperationComplete(params, input) {
5044450444
throw new Error(`Timeout after ${maxWaitTime} seconds`);
5044550445
});
5044650446
}
50447-
function executeExistingChangeSet(cfn, stackName, changeSetId) {
50448-
return __awaiter(this, void 0, void 0, function* () {
50447+
function executeExistingChangeSet(cfn_1, stackName_1, changeSetId_1) {
50448+
return __awaiter(this, arguments, void 0, function* (cfn, stackName, changeSetId, maxWaitTime = 21000) {
5044950449
core.debug(`Executing existing change set: ${changeSetId}`);
5045050450
yield cfn.send(new client_cloudformation_1.ExecuteChangeSetCommand({
5045150451
ChangeSetName: changeSetId,
5045250452
StackName: stackName
5045350453
}));
5045450454
core.debug('Waiting for CloudFormation stack operation to complete');
50455-
yield waitUntilStackOperationComplete({ client: cfn, maxWaitTime: 43200, minDelay: 10 }, { StackName: stackName });
50455+
try {
50456+
yield waitUntilStackOperationComplete({ client: cfn, maxWaitTime, minDelay: 10 }, { StackName: stackName });
50457+
}
50458+
catch (error) {
50459+
if (error instanceof Error && error.message.includes('Timeout after')) {
50460+
core.warning(`Stack operation exceeded ${maxWaitTime / 60} minutes but may still be in progress. ` +
50461+
`Check AWS CloudFormation console for stack '${stackName}' status.`);
50462+
const stack = yield getStack(cfn, stackName);
50463+
return stack === null || stack === void 0 ? void 0 : stack.StackId;
50464+
}
50465+
throw error;
50466+
}
5045650467
const stack = yield getStack(cfn, stackName);
5045750468
return stack === null || stack === void 0 ? void 0 : stack.StackId;
5045850469
});
@@ -50566,8 +50577,8 @@ function cleanupChangeSet(cfn, stack, params, failOnEmptyChangeSet, noDeleteFail
5056650577
}
5056750578
});
5056850579
}
50569-
function updateStack(cfn, stack, params, failOnEmptyChangeSet, noExecuteChangeSet, noDeleteFailedChangeSet) {
50570-
return __awaiter(this, void 0, void 0, function* () {
50580+
function updateStack(cfn_1, stack_1, params_1, failOnEmptyChangeSet_1, noExecuteChangeSet_1, noDeleteFailedChangeSet_1) {
50581+
return __awaiter(this, arguments, void 0, function* (cfn, stack, params, failOnEmptyChangeSet, noExecuteChangeSet, noDeleteFailedChangeSet, maxWaitTime = 21000) {
5057150582
var _a, _b, _c, _d;
5057250583
core.debug('Creating CloudFormation Change Set');
5057350584
const createResponse = yield cfn.send(new client_cloudformation_1.CreateChangeSetCommand(params));
@@ -50600,13 +50611,19 @@ function updateStack(cfn, stack, params, failOnEmptyChangeSet, noExecuteChangeSe
5060050611
try {
5060150612
yield waitUntilStackOperationComplete({
5060250613
client: cfn,
50603-
maxWaitTime: 43200,
50614+
maxWaitTime,
5060450615
minDelay: 10
5060550616
}, {
5060650617
StackName: params.StackName
5060750618
});
5060850619
}
5060950620
catch (error) {
50621+
// Handle timeout gracefully
50622+
if (error instanceof Error && error.message.includes('Timeout after')) {
50623+
core.warning(`Stack operation exceeded ${maxWaitTime / 60} minutes but may still be in progress. ` +
50624+
`Check AWS CloudFormation console for stack '${params.StackName}' status.`);
50625+
return { stackId: stack.StackId };
50626+
}
5061050627
// Get execution failure details using OperationId
5061150628
const stackResponse = yield cfn.send(new client_cloudformation_1.DescribeStacksCommand({ StackName: params.StackName }));
5061250629
const executionOp = (_c = (_b = (_a = stackResponse.Stacks) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.LastOperations) === null || _c === void 0 ? void 0 : _c.find(op => op.OperationType === 'UPDATE_STACK' ||
@@ -50684,17 +50701,17 @@ function buildUpdateChangeSetParams(params, changeSetName) {
5068450701
DeploymentMode: params.DeploymentMode // Only valid for UPDATE change sets
5068550702
};
5068650703
}
50687-
function deployStack(cfn, params, changeSetName, failOnEmptyChangeSet, noExecuteChangeSet, noDeleteFailedChangeSet) {
50688-
return __awaiter(this, void 0, void 0, function* () {
50704+
function deployStack(cfn_1, params_1, changeSetName_1, failOnEmptyChangeSet_1, noExecuteChangeSet_1, noDeleteFailedChangeSet_1) {
50705+
return __awaiter(this, arguments, void 0, function* (cfn, params, changeSetName, failOnEmptyChangeSet, noExecuteChangeSet, noDeleteFailedChangeSet, maxWaitTime = 21000) {
5068950706
const stack = yield getStack(cfn, params.StackName);
5069050707
if (!stack) {
5069150708
core.debug(`Creating CloudFormation Stack via Change Set`);
5069250709
const createParams = buildCreateChangeSetParams(params, changeSetName);
50693-
return yield updateStack(cfn, { StackId: undefined }, createParams, failOnEmptyChangeSet, noExecuteChangeSet, noDeleteFailedChangeSet);
50710+
return yield updateStack(cfn, { StackId: undefined }, createParams, failOnEmptyChangeSet, noExecuteChangeSet, noDeleteFailedChangeSet, maxWaitTime);
5069450711
}
5069550712
core.debug(`Updating CloudFormation Stack via Change Set`);
5069650713
const updateParams = buildUpdateChangeSetParams(params, changeSetName);
50697-
return yield updateStack(cfn, stack, updateParams, failOnEmptyChangeSet, noExecuteChangeSet, noDeleteFailedChangeSet);
50714+
return yield updateStack(cfn, stack, updateParams, failOnEmptyChangeSet, noExecuteChangeSet, noDeleteFailedChangeSet, maxWaitTime);
5069850715
});
5069950716
}
5070050717
function getStackOutputs(cfn, stackId) {
@@ -50834,7 +50851,13 @@ function run() {
5083450851
const cfn = new client_cloudformation_1.CloudFormationClient(Object.assign({}, clientConfiguration));
5083550852
// Execute existing change set mode
5083650853
if (inputs.mode === 'execute-only') {
50837-
const stackId = yield (0, deploy_1.executeExistingChangeSet)(cfn, inputs.name, inputs['execute-change-set-id']);
50854+
// Calculate maxWaitTime for execute-only mode
50855+
const defaultMaxWaitTime = 21000; // 5 hours 50 minutes in seconds
50856+
const timeoutMinutes = inputs['timeout-in-minutes'];
50857+
const maxWaitTime = typeof timeoutMinutes === 'number'
50858+
? timeoutMinutes * 60
50859+
: defaultMaxWaitTime;
50860+
const stackId = yield (0, deploy_1.executeExistingChangeSet)(cfn, inputs.name, inputs['execute-change-set-id'], maxWaitTime);
5083850861
core.setOutput('stack-id', stackId || 'UNKNOWN');
5083950862
if (stackId) {
5084050863
const outputs = yield (0, deploy_1.getStackOutputs)(cfn, stackId);
@@ -50874,7 +50897,13 @@ function run() {
5087450897
DeploymentMode: inputs['deployment-mode'],
5087550898
Parameters: inputs['parameter-overrides']
5087650899
};
50877-
const result = yield (0, deploy_1.deployStack)(cfn, params, inputs['change-set-name'] || `${params.StackName}-CS`, inputs['fail-on-empty-changeset'], inputs['no-execute-changeset'] || inputs.mode === 'create-only', inputs['no-delete-failed-changeset']);
50900+
// Calculate maxWaitTime: use timeout-in-minutes if provided, otherwise default to 5h50m (safe for GitHub Actions 6h limit)
50901+
const defaultMaxWaitTime = 21000; // 5 hours 50 minutes in seconds
50902+
const timeoutMinutes = inputs['timeout-in-minutes'];
50903+
const maxWaitTime = typeof timeoutMinutes === 'number'
50904+
? timeoutMinutes * 60
50905+
: defaultMaxWaitTime;
50906+
const result = yield (0, deploy_1.deployStack)(cfn, params, inputs['change-set-name'] || `${params.StackName}-CS`, inputs['fail-on-empty-changeset'], inputs['no-execute-changeset'] || inputs.mode === 'create-only', inputs['no-delete-failed-changeset'], maxWaitTime);
5087850907
core.setOutput('stack-id', result.stackId || 'UNKNOWN');
5087950908
// Set change set outputs when not executing
5088050909
if (result.changeSetInfo) {

0 commit comments

Comments
 (0)