diff --git a/__tests__/deploy.test.ts b/__tests__/deploy.test.ts index 4cc6b85..4088be8 100644 --- a/__tests__/deploy.test.ts +++ b/__tests__/deploy.test.ts @@ -4,14 +4,27 @@ import { DescribeChangeSetCommand, DescribeEventsCommand, CreateChangeSetCommand, + ExecuteChangeSetCommand, StackStatus, - ChangeSetStatus + ChangeSetStatus, + Stack } from '@aws-sdk/client-cloudformation' import { mockClient } from 'aws-sdk-client-mock' -import { waitUntilStackOperationComplete, updateStack } from '../src/deploy' +import { + waitUntilStackOperationComplete, + updateStack, + executeExistingChangeSet +} from '../src/deploy' import * as core from '@actions/core' -jest.mock('@actions/core') +jest.mock('@actions/core', () => ({ + ...jest.requireActual('@actions/core'), + info: jest.fn(), + warning: jest.fn(), + setOutput: jest.fn(), + setFailed: jest.fn(), + debug: jest.fn() +})) const mockCfnClient = mockClient(CloudFormationClient) const cfn = new CloudFormationClient({ region: 'us-east-1' }) @@ -25,6 +38,7 @@ describe('Deploy error scenarios', () => { afterEach(() => { jest.useRealTimers() + jest.restoreAllMocks() }) describe('waitUntilStackOperationComplete', () => { @@ -363,4 +377,191 @@ describe('Deploy error scenarios', () => { ) }) }) + + describe('Timeout handling', () => { + it('should timeout after maxWaitTime', async () => { + const realDateNow = Date.now + const realSetTimeout = global.setTimeout + let mockTime = 1000000 + Date.now = jest.fn(() => mockTime) + // Mock setTimeout to resolve immediately + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(global.setTimeout as any) = jest.fn((cb: () => void) => { + cb() + return 0 as unknown as NodeJS.Timeout + }) + + mockCfnClient.on(DescribeStacksCommand).callsFake(() => { + // Advance mock time by 2 seconds each call + mockTime += 2000 + return { + Stacks: [ + { + StackName: 'TestStack', + StackStatus: StackStatus.CREATE_IN_PROGRESS, + CreationTime: new Date() + } + ] + } + }) + + await expect( + waitUntilStackOperationComplete( + { client: cfn, maxWaitTime: 1, minDelay: 0 }, + { StackName: 'TestStack' } + ) + ).rejects.toThrow('Timeout after 1 seconds') + + Date.now = realDateNow + global.setTimeout = realSetTimeout + }) + + it('should handle timeout gracefully in executeExistingChangeSet', async () => { + const realDateNow = Date.now + const realSetTimeout = global.setTimeout + let mockTime = 1000000 + Date.now = jest.fn(() => mockTime) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(global.setTimeout as any) = jest.fn((cb: () => void) => { + cb() + return 0 as unknown as NodeJS.Timeout + }) + + mockCfnClient + .on(ExecuteChangeSetCommand) + .resolves({}) + .on(DescribeStacksCommand) + .callsFake(() => { + mockTime += 2000 + return { + Stacks: [ + { + StackName: 'TestStack', + StackId: 'test-stack-id', + StackStatus: StackStatus.UPDATE_IN_PROGRESS, + CreationTime: new Date() + } + ] + } + }) + + const result = await executeExistingChangeSet( + cfn, + 'TestStack', + 'test-cs-id', + 1 // 1 second timeout + ) + + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining('Stack operation exceeded') + ) + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining('TestStack') + ) + expect(result).toBe('test-stack-id') + + Date.now = realDateNow + global.setTimeout = realSetTimeout + }) + + it('should handle timeout gracefully in updateStack', async () => { + const realDateNow = Date.now + const realSetTimeout = global.setTimeout + let mockTime = 1000000 + Date.now = jest.fn(() => mockTime) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(global.setTimeout as any) = jest.fn((cb: () => void) => { + cb() + return 0 as unknown as NodeJS.Timeout + }) + + mockCfnClient + .on(CreateChangeSetCommand) + .resolves({ Id: 'test-cs-id' }) + .on(DescribeChangeSetCommand) + .resolves({ + Status: ChangeSetStatus.CREATE_COMPLETE, + Changes: [] + }) + .on(ExecuteChangeSetCommand) + .resolves({}) + .on(DescribeStacksCommand) + .callsFake(() => { + mockTime += 2000 + return { + Stacks: [ + { + StackName: 'TestStack', + StackId: 'test-stack-id', + StackStatus: StackStatus.UPDATE_IN_PROGRESS, + CreationTime: new Date() + } + ] + } + }) + + const result = await updateStack( + cfn, + { StackId: 'test-stack-id', StackName: 'TestStack' } as Stack, + { + StackName: 'TestStack', + ChangeSetName: 'test-cs', + ChangeSetType: 'UPDATE' + }, + false, + false, // Execute the change set + false, + 1 // 1 second timeout + ) + + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining('Stack operation exceeded') + ) + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining('TestStack') + ) + expect(result.stackId).toBe('test-stack-id') + + Date.now = realDateNow + global.setTimeout = realSetTimeout + }) + + it('should accept custom maxWaitTime parameter', async () => { + mockCfnClient + .on(CreateChangeSetCommand) + .resolves({ Id: 'test-cs-id' }) + .on(DescribeChangeSetCommand) + .resolves({ + Status: ChangeSetStatus.CREATE_COMPLETE, + Changes: [] + }) + .on(DescribeStacksCommand) + .resolves({ + Stacks: [ + { + StackName: 'TestStack', + StackId: 'test-stack-id', + StackStatus: StackStatus.CREATE_COMPLETE, + CreationTime: new Date() + } + ] + }) + + const result = await updateStack( + cfn, + { StackId: 'test-stack-id', StackName: 'TestStack' } as Stack, + { + StackName: 'TestStack', + ChangeSetName: 'test-cs', + ChangeSetType: 'UPDATE' + }, + false, + true, // noExecuteChangeSet - skip execution + false, + 300 // Custom maxWaitTime + ) + + expect(result.stackId).toBe('test-stack-id') + }) + }) }) diff --git a/dist/index.js b/dist/index.js index baa18b2..b3f4314 100644 --- a/dist/index.js +++ b/dist/index.js @@ -50444,15 +50444,26 @@ function waitUntilStackOperationComplete(params, input) { throw new Error(`Timeout after ${maxWaitTime} seconds`); }); } -function executeExistingChangeSet(cfn, stackName, changeSetId) { - return __awaiter(this, void 0, void 0, function* () { +function executeExistingChangeSet(cfn_1, stackName_1, changeSetId_1) { + return __awaiter(this, arguments, void 0, function* (cfn, stackName, changeSetId, maxWaitTime = 21000) { core.debug(`Executing existing change set: ${changeSetId}`); yield cfn.send(new client_cloudformation_1.ExecuteChangeSetCommand({ ChangeSetName: changeSetId, StackName: stackName })); core.debug('Waiting for CloudFormation stack operation to complete'); - yield waitUntilStackOperationComplete({ client: cfn, maxWaitTime: 43200, minDelay: 10 }, { StackName: stackName }); + try { + yield waitUntilStackOperationComplete({ client: cfn, maxWaitTime, minDelay: 10 }, { StackName: stackName }); + } + catch (error) { + if (error instanceof Error && error.message.includes('Timeout after')) { + core.warning(`Stack operation exceeded ${maxWaitTime / 60} minutes but may still be in progress. ` + + `Check AWS CloudFormation console for stack '${stackName}' status.`); + const stack = yield getStack(cfn, stackName); + return stack === null || stack === void 0 ? void 0 : stack.StackId; + } + throw error; + } const stack = yield getStack(cfn, stackName); return stack === null || stack === void 0 ? void 0 : stack.StackId; }); @@ -50566,8 +50577,8 @@ function cleanupChangeSet(cfn, stack, params, failOnEmptyChangeSet, noDeleteFail } }); } -function updateStack(cfn, stack, params, failOnEmptyChangeSet, noExecuteChangeSet, noDeleteFailedChangeSet) { - return __awaiter(this, void 0, void 0, function* () { +function updateStack(cfn_1, stack_1, params_1, failOnEmptyChangeSet_1, noExecuteChangeSet_1, noDeleteFailedChangeSet_1) { + return __awaiter(this, arguments, void 0, function* (cfn, stack, params, failOnEmptyChangeSet, noExecuteChangeSet, noDeleteFailedChangeSet, maxWaitTime = 21000) { var _a, _b, _c, _d; core.debug('Creating CloudFormation Change Set'); const createResponse = yield cfn.send(new client_cloudformation_1.CreateChangeSetCommand(params)); @@ -50600,13 +50611,19 @@ function updateStack(cfn, stack, params, failOnEmptyChangeSet, noExecuteChangeSe try { yield waitUntilStackOperationComplete({ client: cfn, - maxWaitTime: 43200, + maxWaitTime, minDelay: 10 }, { StackName: params.StackName }); } catch (error) { + // Handle timeout gracefully + if (error instanceof Error && error.message.includes('Timeout after')) { + core.warning(`Stack operation exceeded ${maxWaitTime / 60} minutes but may still be in progress. ` + + `Check AWS CloudFormation console for stack '${params.StackName}' status.`); + return { stackId: stack.StackId }; + } // Get execution failure details using OperationId const stackResponse = yield cfn.send(new client_cloudformation_1.DescribeStacksCommand({ StackName: params.StackName })); 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) { DeploymentMode: params.DeploymentMode // Only valid for UPDATE change sets }; } -function deployStack(cfn, params, changeSetName, failOnEmptyChangeSet, noExecuteChangeSet, noDeleteFailedChangeSet) { - return __awaiter(this, void 0, void 0, function* () { +function deployStack(cfn_1, params_1, changeSetName_1, failOnEmptyChangeSet_1, noExecuteChangeSet_1, noDeleteFailedChangeSet_1) { + return __awaiter(this, arguments, void 0, function* (cfn, params, changeSetName, failOnEmptyChangeSet, noExecuteChangeSet, noDeleteFailedChangeSet, maxWaitTime = 21000) { const stack = yield getStack(cfn, params.StackName); if (!stack) { core.debug(`Creating CloudFormation Stack via Change Set`); const createParams = buildCreateChangeSetParams(params, changeSetName); - return yield updateStack(cfn, { StackId: undefined }, createParams, failOnEmptyChangeSet, noExecuteChangeSet, noDeleteFailedChangeSet); + return yield updateStack(cfn, { StackId: undefined }, createParams, failOnEmptyChangeSet, noExecuteChangeSet, noDeleteFailedChangeSet, maxWaitTime); } core.debug(`Updating CloudFormation Stack via Change Set`); const updateParams = buildUpdateChangeSetParams(params, changeSetName); - return yield updateStack(cfn, stack, updateParams, failOnEmptyChangeSet, noExecuteChangeSet, noDeleteFailedChangeSet); + return yield updateStack(cfn, stack, updateParams, failOnEmptyChangeSet, noExecuteChangeSet, noDeleteFailedChangeSet, maxWaitTime); }); } function getStackOutputs(cfn, stackId) { @@ -50834,7 +50851,13 @@ function run() { const cfn = new client_cloudformation_1.CloudFormationClient(Object.assign({}, clientConfiguration)); // Execute existing change set mode if (inputs.mode === 'execute-only') { - const stackId = yield (0, deploy_1.executeExistingChangeSet)(cfn, inputs.name, inputs['execute-change-set-id']); + // Calculate maxWaitTime for execute-only mode + const defaultMaxWaitTime = 21000; // 5 hours 50 minutes in seconds + const timeoutMinutes = inputs['timeout-in-minutes']; + const maxWaitTime = typeof timeoutMinutes === 'number' + ? timeoutMinutes * 60 + : defaultMaxWaitTime; + const stackId = yield (0, deploy_1.executeExistingChangeSet)(cfn, inputs.name, inputs['execute-change-set-id'], maxWaitTime); core.setOutput('stack-id', stackId || 'UNKNOWN'); if (stackId) { const outputs = yield (0, deploy_1.getStackOutputs)(cfn, stackId); @@ -50874,7 +50897,13 @@ function run() { DeploymentMode: inputs['deployment-mode'], Parameters: inputs['parameter-overrides'] }; - 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']); + // Calculate maxWaitTime: use timeout-in-minutes if provided, otherwise default to 5h50m (safe for GitHub Actions 6h limit) + const defaultMaxWaitTime = 21000; // 5 hours 50 minutes in seconds + const timeoutMinutes = inputs['timeout-in-minutes']; + const maxWaitTime = typeof timeoutMinutes === 'number' + ? timeoutMinutes * 60 + : defaultMaxWaitTime; + 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); core.setOutput('stack-id', result.stackId || 'UNKNOWN'); // Set change set outputs when not executing if (result.changeSetInfo) { diff --git a/src/deploy.ts b/src/deploy.ts index d83fb10..3ecc92e 100644 --- a/src/deploy.ts +++ b/src/deploy.ts @@ -88,7 +88,8 @@ export async function waitUntilStackOperationComplete( export async function executeExistingChangeSet( cfn: CloudFormationClient, stackName: string, - changeSetId: string + changeSetId: string, + maxWaitTime: number = 21000 ): Promise { core.debug(`Executing existing change set: ${changeSetId}`) @@ -100,10 +101,22 @@ export async function executeExistingChangeSet( ) core.debug('Waiting for CloudFormation stack operation to complete') - await waitUntilStackOperationComplete( - { client: cfn, maxWaitTime: 43200, minDelay: 10 }, - { StackName: stackName } - ) + try { + await waitUntilStackOperationComplete( + { client: cfn, maxWaitTime, minDelay: 10 }, + { StackName: stackName } + ) + } catch (error) { + if (error instanceof Error && error.message.includes('Timeout after')) { + core.warning( + `Stack operation exceeded ${maxWaitTime / 60} minutes but may still be in progress. ` + + `Check AWS CloudFormation console for stack '${stackName}' status.` + ) + const stack = await getStack(cfn, stackName) + return stack?.StackId + } + throw error + } const stack = await getStack(cfn, stackName) return stack?.StackId @@ -267,7 +280,8 @@ export async function updateStack( params: CreateChangeSetInput, failOnEmptyChangeSet: boolean, noExecuteChangeSet: boolean, - noDeleteFailedChangeSet: boolean + noDeleteFailedChangeSet: boolean, + maxWaitTime: number = 21000 ): Promise<{ stackId?: string; changeSetInfo?: ChangeSetInfo }> { core.debug('Creating CloudFormation Change Set') const createResponse = await cfn.send(new CreateChangeSetCommand(params)) @@ -330,7 +344,7 @@ export async function updateStack( await waitUntilStackOperationComplete( { client: cfn, - maxWaitTime: 43200, + maxWaitTime, minDelay: 10 }, { @@ -338,6 +352,15 @@ export async function updateStack( } ) } catch (error) { + // Handle timeout gracefully + if (error instanceof Error && error.message.includes('Timeout after')) { + core.warning( + `Stack operation exceeded ${maxWaitTime / 60} minutes but may still be in progress. ` + + `Check AWS CloudFormation console for stack '${params.StackName}' status.` + ) + return { stackId: stack.StackId } + } + // Get execution failure details using OperationId const stackResponse = await cfn.send( new DescribeStacksCommand({ StackName: params.StackName! }) @@ -449,7 +472,8 @@ export async function deployStack( changeSetName: string, failOnEmptyChangeSet: boolean, noExecuteChangeSet: boolean, - noDeleteFailedChangeSet: boolean + noDeleteFailedChangeSet: boolean, + maxWaitTime: number = 21000 ): Promise<{ stackId?: string; changeSetInfo?: ChangeSetInfo }> { const stack = await getStack(cfn, params.StackName) @@ -463,7 +487,8 @@ export async function deployStack( createParams, failOnEmptyChangeSet, noExecuteChangeSet, - noDeleteFailedChangeSet + noDeleteFailedChangeSet, + maxWaitTime ) } @@ -476,7 +501,8 @@ export async function deployStack( updateParams, failOnEmptyChangeSet, noExecuteChangeSet, - noDeleteFailedChangeSet + noDeleteFailedChangeSet, + maxWaitTime ) } diff --git a/src/main.ts b/src/main.ts index f63daf7..97e59db 100644 --- a/src/main.ts +++ b/src/main.ts @@ -109,10 +109,19 @@ export async function run(): Promise { // Execute existing change set mode if (inputs.mode === 'execute-only') { + // Calculate maxWaitTime for execute-only mode + const defaultMaxWaitTime = 21000 // 5 hours 50 minutes in seconds + const timeoutMinutes = inputs['timeout-in-minutes'] + const maxWaitTime = + typeof timeoutMinutes === 'number' + ? timeoutMinutes * 60 + : defaultMaxWaitTime + const stackId = await executeExistingChangeSet( cfn, inputs.name, - inputs['execute-change-set-id']! + inputs['execute-change-set-id']!, + maxWaitTime ) core.setOutput('stack-id', stackId || 'UNKNOWN') @@ -157,13 +166,22 @@ export async function run(): Promise { Parameters: inputs['parameter-overrides'] } + // Calculate maxWaitTime: use timeout-in-minutes if provided, otherwise default to 5h50m (safe for GitHub Actions 6h limit) + const defaultMaxWaitTime = 21000 // 5 hours 50 minutes in seconds + const timeoutMinutes = inputs['timeout-in-minutes'] + const maxWaitTime = + typeof timeoutMinutes === 'number' + ? timeoutMinutes * 60 + : defaultMaxWaitTime + const result = await 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'] + inputs['no-delete-failed-changeset'], + maxWaitTime ) core.setOutput('stack-id', result.stackId || 'UNKNOWN')