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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ This action requires the following minimum set of permissions:
"cloudformation:CreateChangeSet",
"cloudformation:DescribeChangeSet",
"cloudformation:DeleteChangeSet",
"cloudformation:DeleteStack",
"cloudformation:ExecuteChangeSet",
"cloudformation:DescribeEvents"
],
Expand Down Expand Up @@ -281,12 +282,14 @@ The action makes the following AWS CloudFormation API calls depending on the ope
- `DescribeEvents` - Retrieve detailed error information for validation failures
- `DeleteChangeSet` - Clean up failed change sets (unless `no-delete-failed-changeset` is set)

**ROLLBACK_COMPLETE Recovery:**

- `DeleteStack` - Automatically delete stacks stuck in `ROLLBACK_COMPLETE` state before recreating

**Event Streaming (during stack operations):**

- `DescribeEvents` - Monitor real-time CloudFormation events during deployment

> The policy above prevents the stack from being deleted - add `cloudformation:DeleteStack` if deletion is required for your use case

## Example

You want to run your microservices with [Amazon Elastic Kubernetes Services](https://aws.amazon.com/eks/) and leverage the best-practices to run the cluster? Using this GitHub Action you can customize and deploy the [modular and scalable Amazon EKS architecture](https://aws.amazon.com/quickstart/architecture/amazon-eks/) provided in an AWS Quick Start to your AWS Account. The following workflow enables you to create and update a Kubernetes cluster using a manual workflow trigger.
Expand Down
309 changes: 309 additions & 0 deletions __tests__/deploy.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {
CloudFormationClient,
CloudFormationServiceException,
DescribeStacksCommand,
DescribeChangeSetCommand,
DescribeEventsCommand,
CreateChangeSetCommand,
DeleteStackCommand,
ExecuteChangeSetCommand,
StackStatus,
ChangeSetStatus,
Expand All @@ -13,6 +15,7 @@ import { mockClient } from 'aws-sdk-client-mock'
import {
waitUntilStackOperationComplete,
updateStack,
deployStack,
executeExistingChangeSet
} from '../src/deploy'
import * as core from '@actions/core'
Expand Down Expand Up @@ -564,4 +567,310 @@ describe('Deploy error scenarios', () => {
expect(result.stackId).toBe('test-stack-id')
})
})

describe('deployStack', () => {
it('deletes and recreates stack in ROLLBACK_COMPLETE state', async () => {
// First call: getStack returns ROLLBACK_COMPLETE
// Second call: waitUntilStackDeleteComplete sees DELETE_COMPLETE
// Third call: waitUntilStackOperationComplete after execute
mockCfnClient
.on(DescribeStacksCommand)
.resolvesOnce({
Stacks: [
{
StackName: 'TestStack',
StackId: 'test-stack-id',
StackStatus: StackStatus.ROLLBACK_COMPLETE,
CreationTime: new Date()
}
]
})
.resolvesOnce({
Stacks: [
{
StackName: 'TestStack',
StackId: 'test-stack-id',
StackStatus: StackStatus.DELETE_COMPLETE,
CreationTime: new Date()
}
]
})
.resolves({
Stacks: [
{
StackName: 'TestStack',
StackId: 'new-stack-id',
StackStatus: StackStatus.CREATE_COMPLETE,
CreationTime: new Date()
}
]
})

mockCfnClient.on(DeleteStackCommand).resolves({})
mockCfnClient
.on(CreateChangeSetCommand)
.resolves({ Id: 'test-cs-id', StackId: 'new-stack-id' })
mockCfnClient.on(DescribeChangeSetCommand).resolves({
Status: ChangeSetStatus.CREATE_COMPLETE,
Changes: [{ Type: 'Resource' }]
})
mockCfnClient.on(ExecuteChangeSetCommand).resolves({})

const result = await deployStack(
cfn,
{
StackName: 'TestStack',
TemplateBody: '{}',
Capabilities: []
},
'test-cs',
false,
false,
false,
60
)

expect(mockCfnClient.commandCalls(DeleteStackCommand)).toHaveLength(1)
expect(core.info).toHaveBeenCalledWith(
expect.stringContaining('ROLLBACK_COMPLETE')
)
expect(result.stackId).toBe('new-stack-id')
})

it('does not delete stack when not in ROLLBACK_COMPLETE state', async () => {
mockCfnClient.on(DescribeStacksCommand).resolves({
Stacks: [
{
StackName: 'TestStack',
StackId: 'test-stack-id',
StackStatus: StackStatus.CREATE_COMPLETE,
CreationTime: new Date()
}
]
})

mockCfnClient
.on(CreateChangeSetCommand)
.resolves({ Id: 'test-cs-id', StackId: 'test-stack-id' })
mockCfnClient.on(DescribeChangeSetCommand).resolves({
Status: ChangeSetStatus.CREATE_COMPLETE,
Changes: [{ Type: 'Resource' }]
})
mockCfnClient.on(ExecuteChangeSetCommand).resolves({})

await deployStack(
cfn,
{
StackName: 'TestStack',
TemplateBody: '{}',
Capabilities: []
},
'test-cs',
false,
false,
false,
60
)

expect(mockCfnClient.commandCalls(DeleteStackCommand)).toHaveLength(0)
})

it('handles delete completing when stack disappears', async () => {
mockCfnClient
.on(DescribeStacksCommand)
.resolvesOnce({
Stacks: [
{
StackName: 'TestStack',
StackId: 'test-stack-id',
StackStatus: StackStatus.ROLLBACK_COMPLETE,
CreationTime: new Date()
}
]
})
.resolvesOnce({ Stacks: [] })
.resolves({
Stacks: [
{
StackName: 'TestStack',
StackId: 'new-stack-id',
StackStatus: StackStatus.CREATE_COMPLETE,
CreationTime: new Date()
}
]
})

mockCfnClient.on(DeleteStackCommand).resolves({})
mockCfnClient
.on(CreateChangeSetCommand)
.resolves({ Id: 'test-cs-id', StackId: 'new-stack-id' })
mockCfnClient.on(DescribeChangeSetCommand).resolves({
Status: ChangeSetStatus.CREATE_COMPLETE,
Changes: [{ Type: 'Resource' }]
})
mockCfnClient.on(ExecuteChangeSetCommand).resolves({})

const result = await deployStack(
cfn,
{ StackName: 'TestStack', TemplateBody: '{}', Capabilities: [] },
'test-cs',
false,
false,
false,
60
)

expect(mockCfnClient.commandCalls(DeleteStackCommand)).toHaveLength(1)
expect(result.stackId).toBe('new-stack-id')
})

it('handles delete completing via ValidationError', async () => {
const validationError = new CloudFormationServiceException({
name: 'ValidationError',
$fault: 'client',
$metadata: { httpStatusCode: 400 },
message: 'Stack does not exist'
})
validationError.name = 'ValidationError'

mockCfnClient
.on(DescribeStacksCommand)
.resolvesOnce({
Stacks: [
{
StackName: 'TestStack',
StackId: 'test-stack-id',
StackStatus: StackStatus.ROLLBACK_COMPLETE,
CreationTime: new Date()
}
]
})
.rejectsOnce(validationError)
.resolves({
Stacks: [
{
StackName: 'TestStack',
StackId: 'new-stack-id',
StackStatus: StackStatus.CREATE_COMPLETE,
CreationTime: new Date()
}
]
})

mockCfnClient.on(DeleteStackCommand).resolves({})
mockCfnClient
.on(CreateChangeSetCommand)
.resolves({ Id: 'test-cs-id', StackId: 'new-stack-id' })
mockCfnClient.on(DescribeChangeSetCommand).resolves({
Status: ChangeSetStatus.CREATE_COMPLETE,
Changes: [{ Type: 'Resource' }]
})
mockCfnClient.on(ExecuteChangeSetCommand).resolves({})

const result = await deployStack(
cfn,
{ StackName: 'TestStack', TemplateBody: '{}', Capabilities: [] },
'test-cs',
false,
false,
false,
60
)

expect(result.stackId).toBe('new-stack-id')
})

it('throws error when stack deletion fails', async () => {
mockCfnClient
.on(DescribeStacksCommand)
.resolvesOnce({
Stacks: [
{
StackName: 'TestStack',
StackId: 'test-stack-id',
StackStatus: StackStatus.ROLLBACK_COMPLETE,
CreationTime: new Date()
}
]
})
.resolves({
Stacks: [
{
StackName: 'TestStack',
StackId: 'test-stack-id',
StackStatus: StackStatus.DELETE_FAILED,
CreationTime: new Date()
}
]
})

mockCfnClient.on(DeleteStackCommand).resolves({})

await expect(
deployStack(
cfn,
{ StackName: 'TestStack', TemplateBody: '{}', Capabilities: [] },
'test-cs',
false,
false,
false,
60
)
).rejects.toThrow('Stack deletion failed for TestStack')
})

it('times out waiting for stack deletion', async () => {
mockCfnClient
.on(DescribeStacksCommand)
.resolvesOnce({
Stacks: [
{
StackName: 'TestStack',
StackId: 'test-stack-id',
StackStatus: StackStatus.ROLLBACK_COMPLETE,
CreationTime: new Date()
}
]
})
.resolves({
Stacks: [
{
StackName: 'TestStack',
StackId: 'test-stack-id',
StackStatus: StackStatus.DELETE_IN_PROGRESS,
CreationTime: new Date()
}
]
})

mockCfnClient.on(DeleteStackCommand).resolves({})

const realDateNow = Date.now
let callCount = 0
Date.now = jest.fn(() => {
callCount++
// First call is the startTime capture, subsequent calls exceed maxWaitTime
return callCount <= 1 ? 0 : 2 * 1000 + 1
})

const realSetTimeout = global.setTimeout
global.setTimeout = ((fn: () => void) =>
realSetTimeout(fn, 0)) as unknown as typeof setTimeout

await expect(
deployStack(
cfn,
{ StackName: 'TestStack', TemplateBody: '{}', Capabilities: [] },
'test-cs',
false,
false,
false,
2
)
).rejects.toThrow('Timeout waiting for stack deletion after 2 seconds')

Date.now = realDateNow
global.setTimeout = realSetTimeout
})
})
})
Loading
Loading