Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 204 additions & 3 deletions __tests__/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand All @@ -25,6 +38,7 @@ describe('Deploy error scenarios', () => {

afterEach(() => {
jest.useRealTimers()
jest.restoreAllMocks()
})

describe('waitUntilStackOperationComplete', () => {
Expand Down Expand Up @@ -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')
})
})
})
53 changes: 41 additions & 12 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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' ||
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading