diff --git a/internal/cli/command.go b/internal/cli/command.go index 645802e..a42c3ff 100644 --- a/internal/cli/command.go +++ b/internal/cli/command.go @@ -34,6 +34,6 @@ func stageAdapter( client *stratus.Client, stack *config.Stack, ) (err error) { - _, err = command.Stage(ctx, client, stack) + _, _, err = command.Stage(ctx, client, stack) return } diff --git a/internal/command/deploy.go b/internal/command/deploy.go index 3f0b1d4..0b13521 100644 --- a/internal/command/deploy.go +++ b/internal/command/deploy.go @@ -1,6 +1,9 @@ package command import ( + "fmt" + "os" + "github.com/72636c/stratus/internal/config" "github.com/72636c/stratus/internal/context" "github.com/72636c/stratus/internal/stratus" @@ -20,7 +23,23 @@ func Deploy( return err } - if changeSet != nil { + if changeSet == nil && os.Getenv("FORCE_DEPLOY") == "true" { + logger.Title("Could not find existing change set. FORCE_DEPLOY is true, so creating a new change set.") + + _, changeSet, err = Stage(ctx, client, stack) + if err != nil { + return err + } + } + + if changeSet == nil { + logger.Title("Could not find existing change set, exiting. To force a deployment with a new change set, set FORCE_DEPLOY=true and retry.") + return fmt.Errorf("could not find existing change set") + } + + if stratus.IsNoopChangeSet(changeSet) { + logger.Title("No changes to execute.") + } else { logger.Title("Execute change set") err = client.ExecuteChangeSet(ctx, stack, *changeSet.ChangeSetName) diff --git a/internal/command/deploy_happy_test.go b/internal/command/deploy_test.go similarity index 70% rename from internal/command/deploy_happy_test.go rename to internal/command/deploy_test.go index e349d91..686249d 100644 --- a/internal/command/deploy_happy_test.go +++ b/internal/command/deploy_test.go @@ -1,6 +1,7 @@ package command_test import ( + "os" "testing" "github.com/aws/aws-sdk-go/aws" @@ -360,9 +361,12 @@ func Test_Deploy_Happy_NoopChangeSet(t *testing.T) { ). Return( &cloudformation.DescribeChangeSetOutput{ - ChangeSetName: aws.String(mockChangeSetUpdateName), - Capabilities: make([]*string, 0), - Parameters: make([]*cloudformation.Parameter, 0), + ChangeSetName: aws.String(mockChangeSetUpdateName), + Capabilities: make([]*string, 0), + Parameters: make([]*cloudformation.Parameter, 0), + ExecutionStatus: aws.String(cloudformation.ExecutionStatusUnavailable), + Status: aws.String(cloudformation.ChangeSetStatusFailed), + StatusReason: aws.String("The submitted information didn't contain changes. Submit different information to create a change set."), }, nil, ). @@ -469,9 +473,12 @@ func Test_Deploy_Happy_NoopChangeSet_UploadArtefacts(t *testing.T) { ). Return( &cloudformation.DescribeChangeSetOutput{ - ChangeSetName: aws.String(mockChangeSetUpdateName), - Capabilities: make([]*string, 0), - Parameters: make([]*cloudformation.Parameter, 0), + ChangeSetName: aws.String(mockChangeSetUpdateName), + Capabilities: make([]*string, 0), + Parameters: make([]*cloudformation.Parameter, 0), + ExecutionStatus: aws.String(cloudformation.ExecutionStatusUnavailable), + Status: aws.String(cloudformation.ChangeSetStatusFailed), + StatusReason: aws.String("The submitted information didn't contain changes. Submit different information to create a change set."), }, nil, ). @@ -513,3 +520,205 @@ func Test_Deploy_Happy_NoopChangeSet_UploadArtefacts(t *testing.T) { err := command.Deploy(context.Background(), client, stack) assert.NoError(err) } + +func Test_Deploy_Happy_ImplicitStage(t *testing.T) { + assert := assert.New(t) + + stack := &config.Stack{ + Name: mockStackName, + + Capabilities: make([]string, 0), + Parameters: make(config.StackParameters, 0), + TerminationProtection: true, + + Policy: []byte(mockStackPolicy), + Template: []byte(mockStackTemplate), + + Checksum: mockChecksum, + } + + os.Setenv("FORCE_DEPLOY", "true") + + cfn := stratus.NewCloudFormationMock() + defer cfn.AssertExpectations(t) + cfn. + On( + "ListChangeSetsWithContext", + &cloudformation.ListChangeSetsInput{ + StackName: aws.String(stack.Name), + }, + ). + Return( + &cloudformation.ListChangeSetsOutput{ + Summaries: []*cloudformation.ChangeSetSummary{}, + }, + nil, + ).Once(). + On( + "ValidateTemplateWithContext", + &cloudformation.ValidateTemplateInput{ + TemplateBody: aws.String(string(stack.Template)), + }, + ). + Return(nil, nil). + On( + "CreateChangeSetWithContext", + &cloudformation.CreateChangeSetInput{ + Capabilities: make([]*string, 0), + ChangeSetName: aws.String(mockChangeSetUpdateName), + ChangeSetType: aws.String(cloudformation.ChangeSetTypeUpdate), + StackName: aws.String(stack.Name), + Parameters: make([]*cloudformation.Parameter, 0), + Tags: make([]*cloudformation.Tag, 0), + TemplateBody: aws.String(string(stack.Template)), + UsePreviousTemplate: aws.Bool(false), + }, + ). + Return(nil, nil). + On( + "WaitUntilChangeSetCreateCompleteWithContext", + &cloudformation.DescribeChangeSetInput{ + ChangeSetName: aws.String(mockChangeSetUpdateName), + StackName: aws.String(stack.Name), + }, + ). + Return(nil). + On( + "DescribeChangeSetWithContext", + &cloudformation.DescribeChangeSetInput{ + ChangeSetName: aws.String(mockChangeSetUpdateName), + StackName: aws.String(stack.Name), + }, + ). + Return( + &cloudformation.DescribeChangeSetOutput{ + ChangeSetName: aws.String(mockChangeSetUpdateName), + Capabilities: make([]*string, 0), + Parameters: make([]*cloudformation.Parameter, 0), + }, + nil, + ). + On( + "DescribeStacksWithContext", + &cloudformation.DescribeStacksInput{ + StackName: aws.String(stack.Name), + }, + ). + Return( + &cloudformation.DescribeStacksOutput{ + Stacks: []*cloudformation.Stack{ + { + EnableTerminationProtection: aws.Bool(false), + }, + }, + }, + nil, + ). + On( + "GetStackPolicyWithContext", + &cloudformation.GetStackPolicyInput{ + StackName: aws.String(stack.Name), + }, + ). + Return(nil, nil). + On( + "DescribeStackEventsWithContext", + &cloudformation.DescribeStackEventsInput{ + StackName: aws.String(stack.Name), + }, + ). + Return( + &cloudformation.DescribeStackEventsOutput{ + StackEvents: make([]*cloudformation.StackEvent, 0), + }, + nil, + ). + On( + "ExecuteChangeSetWithContext", + &cloudformation.ExecuteChangeSetInput{ + ChangeSetName: aws.String(mockChangeSetUpdateName), + StackName: aws.String(mockStackName), + }, + ). + Return(nil, nil). + On( + "DescribeStackEventsWithContext", + &cloudformation.DescribeStackEventsInput{ + StackName: aws.String(stack.Name), + }, + ). + Return( + &cloudformation.DescribeStackEventsOutput{ + StackEvents: make([]*cloudformation.StackEvent, 0), + }, + nil, + ). + On( + "WaitUntilStackUpdateCompleteWithContext", + &cloudformation.DescribeStacksInput{ + StackName: aws.String(mockStackName), + }, + ). + Return(nil). + On( + "SetStackPolicyWithContext", + &cloudformation.SetStackPolicyInput{ + StackName: aws.String(mockStackName), + StackPolicyBody: aws.String(mockStackPolicy), + }, + ). + Return(nil, nil). + On( + "UpdateTerminationProtectionWithContext", + &cloudformation.UpdateTerminationProtectionInput{ + EnableTerminationProtection: aws.Bool(true), + StackName: aws.String(mockStackName), + }, + ). + Return(nil, nil) + + client := stratus.NewClient(cfn, nil) + + err := command.Deploy(context.Background(), client, stack) + assert.NoError(err) + + os.Unsetenv("FORCE_DEPLOY") +} + +func Test_Deploy_NoImplicitStage_Fails(t *testing.T) { + assert := assert.New(t) + + stack := &config.Stack{ + Name: mockStackName, + + Capabilities: make([]string, 0), + Parameters: make(config.StackParameters, 0), + TerminationProtection: true, + + Policy: []byte(mockStackPolicy), + Template: []byte(mockStackTemplate), + + Checksum: mockChecksum, + } + + cfn := stratus.NewCloudFormationMock() + defer cfn.AssertExpectations(t) + cfn. + On( + "ListChangeSetsWithContext", + &cloudformation.ListChangeSetsInput{ + StackName: aws.String(stack.Name), + }, + ). + Return( + &cloudformation.ListChangeSetsOutput{ + Summaries: []*cloudformation.ChangeSetSummary{}, + }, + nil, + ) + + client := stratus.NewClient(cfn, nil) + + err := command.Deploy(context.Background(), client, stack) + assert.Error(err) +} diff --git a/internal/command/stage.go b/internal/command/stage.go index 909b0df..a418647 100644 --- a/internal/command/stage.go +++ b/internal/command/stage.go @@ -4,20 +4,21 @@ import ( "github.com/72636c/stratus/internal/config" "github.com/72636c/stratus/internal/context" "github.com/72636c/stratus/internal/stratus" + "github.com/aws/aws-sdk-go/service/cloudformation" ) func Stage( ctx context.Context, client *stratus.Client, stack *config.Stack, -) (*stratus.Diff, error) { +) (*stratus.Diff, *cloudformation.DescribeChangeSetOutput, error) { logger := context.Logger(ctx) logger.Title("Validate template") validateOutput, err := client.ValidateTemplate(ctx, stack) if err != nil { - return nil, err + return nil, nil, err } logger.Data(validateOutput) @@ -27,7 +28,7 @@ func Stage( err = client.UploadArtefacts(ctx, stack) if err != nil { - return nil, err + return nil, nil, err } } @@ -35,17 +36,17 @@ func Stage( describeOutput, err := client.CreateChangeSet(ctx, stack) if err != nil { - return nil, err + return nil, nil, err } logger.Title("Diff stack") diffOutput, err := client.Diff(ctx, stack, describeOutput) if err != nil { - return nil, err + return nil, nil, err } logger.Data(diffOutput) - return diffOutput, nil + return diffOutput, describeOutput, nil } diff --git a/internal/command/stage_happy_test.go b/internal/command/stage_happy_test.go index 0e8cfc9..1465604 100644 --- a/internal/command/stage_happy_test.go +++ b/internal/command/stage_happy_test.go @@ -111,7 +111,7 @@ func Test_Stage_Happy_CreateChangeSet(t *testing.T) { client := stratus.NewClient(cfn, nil) - _, err := command.Stage(context.Background(), client, stack) + _, _, err := command.Stage(context.Background(), client, stack) assert.NoError(err) } @@ -168,8 +168,9 @@ func Test_Stage_Happy_NoopChangeSet(t *testing.T) { ). Return( &cloudformation.DescribeChangeSetOutput{ - Status: aws.String(cloudformation.ChangeSetStatusFailed), - StatusReason: aws.String("The submitted information didn't contain changes. Submit different information to create a change set."), + Status: aws.String(cloudformation.ChangeSetStatusFailed), + StatusReason: aws.String("The submitted information didn't contain changes. Submit different information to create a change set."), + ExecutionStatus: aws.String(cloudformation.ExecutionStatusUnavailable), }, nil, ). @@ -199,7 +200,7 @@ func Test_Stage_Happy_NoopChangeSet(t *testing.T) { client := stratus.NewClient(cfn, nil) - _, err := command.Stage(context.Background(), client, stack) + _, _, err := command.Stage(context.Background(), client, stack) assert.NoError(err) } @@ -260,8 +261,9 @@ func Test_Stage_Happy_NoopChangeSet_UploadArtefacts(t *testing.T) { ). Return( &cloudformation.DescribeChangeSetOutput{ - Status: aws.String(cloudformation.ChangeSetStatusFailed), - StatusReason: aws.String("The submitted information didn't contain changes. Submit different information to create a change set."), + Status: aws.String(cloudformation.ChangeSetStatusFailed), + StatusReason: aws.String("The submitted information didn't contain changes. Submit different information to create a change set."), + ExecutionStatus: aws.String(cloudformation.ExecutionStatusUnavailable), }, nil, ). @@ -321,7 +323,7 @@ func Test_Stage_Happy_NoopChangeSet_UploadArtefacts(t *testing.T) { client := stratus.NewClient(cfn, s3Client) - _, err := command.Stage(context.Background(), client, stack) + _, _, err := command.Stage(context.Background(), client, stack) assert.NoError(err) } @@ -403,6 +405,6 @@ func Test_Stage_Happy_UpdateChangeSet(t *testing.T) { client := stratus.NewClient(cfn, nil) - _, err := command.Stage(context.Background(), client, stack) + _, _, err := command.Stage(context.Background(), client, stack) assert.NoError(err) } diff --git a/internal/stratus/client.go b/internal/stratus/client.go index 1a06b7d..1c1d604 100644 --- a/internal/stratus/client.go +++ b/internal/stratus/client.go @@ -222,7 +222,7 @@ func (client *Client) FindExistingChangeSet( ) (*cloudformation.DescribeChangeSetOutput, error) { listOutput, err := client.listChangeSets(ctx, stack) if isStackDoesNotExistError(err) { - return nil, fmt.Errorf("stack '%s' does not exist", stack.Name) + return nil, nil } if err != nil { return nil, err @@ -263,15 +263,11 @@ func (client *Client) FindExistingChangeSet( ) } - if *summary.ExecutionStatus == cloudformation.ExecutionStatusUnavailable { - return nil, nil - } - return changeSetOutput, nil } } - return nil, fmt.Errorf("change set '*%s*' does not exist", stack.Checksum) + return nil, nil } func (client *Client) SetStackPolicy( @@ -451,7 +447,7 @@ func (client *Client) handleCreateChangeSetError( return err } - if !isNoopChangeSet(describeOutput) { + if !IsNoopChangeSet(describeOutput) { return err } diff --git a/internal/stratus/utils.go b/internal/stratus/utils.go index 9d705f8..b6b8d44 100644 --- a/internal/stratus/utils.go +++ b/internal/stratus/utils.go @@ -114,10 +114,12 @@ func isAcceptableChangeSetStatus( return false } -func isNoopChangeSet(output *cloudformation.DescribeChangeSetOutput) bool { +func IsNoopChangeSet(output *cloudformation.DescribeChangeSetOutput) bool { return output != nil && output.Status != nil && output.StatusReason != nil && + output.ExecutionStatus != nil && + *output.ExecutionStatus == cloudformation.ExecutionStatusUnavailable && *output.Status == cloudformation.ChangeSetStatusFailed && *output.StatusReason == noopChangeSetStatusReason }