diff --git a/README.md b/README.md index c0f6676..261f37b 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ state of the stack deployment. - YAML and JSON formatted stack policies - Deploy using an assumed IAM role (often used to deploy stacks to other accounts) +- Enable Termination Protection at deployment time More features are currently on the roadmap, which can be [found on Trello](https://trello.com/b/ECuGN86A) diff --git a/commands/deploy.go b/commands/deploy.go index e5b71ef..92bdae5 100644 --- a/commands/deploy.go +++ b/commands/deploy.go @@ -152,5 +152,13 @@ func init() { ) deployCmd.MarkFlagFilename("stack-policy-file") + deployCmd.PersistentFlags().BoolVar( + &stack.TerminationProtection, + "termination-protection", + false, + "Set termination protection for this stack", + ) + deployCmd.MarkFlagFilename("stack-policy-file") + rootCmd.AddCommand(deployCmd) } diff --git a/forgelib/deploy.go b/forgelib/deploy.go index 6c81675..5d87659 100644 --- a/forgelib/deploy.go +++ b/forgelib/deploy.go @@ -126,5 +126,14 @@ func (s *Stack) Deploy() (output DeployOut, err error) { return output, err } } + // Only SET termination protection, do not remove + if s.TerminationProtection != false { + cfnClient.UpdateTerminationProtection( + &cloudformation.UpdateTerminationProtectionInput{ + EnableTerminationProtection: aws.Bool(s.TerminationProtection), + StackName: aws.String(s.StackID), + }, + ) + } return } diff --git a/forgelib/deploy_test.go b/forgelib/deploy_test.go index a06684b..64e7441 100644 --- a/forgelib/deploy_test.go +++ b/forgelib/deploy_test.go @@ -13,6 +13,7 @@ type fakeStack struct { RoleARN, StackName, StackID, StackStatus string Tags []fakeTag Parameters []fakeParameter + EnableTerminationProtection bool } type fakeTag struct { @@ -60,30 +61,35 @@ func genFakeStackData(realStack cloudformation.Stack) fakeStack { output.RoleARN = *r } + if t := realStack.EnableTerminationProtection; t != nil { + output.EnableTerminationProtection = *t + } + return output } func TestDeploy(t *testing.T) { cases := []struct { - accountID string - capabilityIam bool - cfnRoleName string - expectFailure bool - expectOutput DeployOut - expectStacks []cloudformation.Stack - expectStackPolicy string - failCreate bool - failDescribe bool - failValidate bool - newStackID string - noUpdates bool - parameterInput string - requiredParameters []string - stacks []cloudformation.Stack - stackPolicies map[string]string - stackPolicyInput string - tagInput string - thisStack Stack + accountID string + capabilityIam bool + cfnRoleName string + expectFailure bool + expectOutput DeployOut + expectStacks []cloudformation.Stack + expectStackPolicy string + failCreate bool + failDescribe bool + failValidate bool + newStackID string + noUpdates bool + parameterInput string + requiredParameters []string + stacks []cloudformation.Stack + stackPolicies map[string]string + stackPolicyInput string + tagInput string + terminationProtection bool + thisStack Stack }{ // Create new stack with previously used name { @@ -659,6 +665,109 @@ func TestDeploy(t *testing.T) { }, expectStackPolicy: `{"Statement":[{"Action":"Update:*","Effect":"Allow","NotResource":"LogicalResourceId/ProductionDatabase","Principal":"*"}]}`, }, + // Create new stack with termination protection + { + terminationProtection: true, + newStackID: "test-stack/id0", + stacks: []cloudformation.Stack{}, + expectStacks: []cloudformation.Stack{ + { + StackName: aws.String("test-stack"), + StackId: aws.String("test-stack/id0"), + StackStatus: aws.String(cloudformation.StackStatusCreateComplete), + EnableTerminationProtection: aws.Bool(true), + }, + }, + }, + // Update stack and turn on termination protection + { + terminationProtection: true, + stacks: []cloudformation.Stack{ + { + StackName: aws.String("test-stack"), + StackId: aws.String("test-stack/id0"), + StackStatus: aws.String(cloudformation.StackStatusDeleteComplete), + }, + { + StackName: aws.String("test-stack"), + StackId: aws.String("test-stack/id1"), + StackStatus: aws.String(cloudformation.StackStatusCreateComplete), + EnableTerminationProtection: aws.Bool(false), + }, + }, + expectStacks: []cloudformation.Stack{ + { + StackName: aws.String("test-stack"), + StackId: aws.String("test-stack/id0"), + StackStatus: aws.String(cloudformation.StackStatusDeleteComplete), + }, + { + StackName: aws.String("test-stack"), + StackId: aws.String("test-stack/id1"), + StackStatus: aws.String(cloudformation.StackStatusUpdateComplete), + EnableTerminationProtection: aws.Bool(true), + }, + }, + }, + // Update stack and leave termination protection alone + { + stacks: []cloudformation.Stack{ + { + StackName: aws.String("test-stack"), + StackId: aws.String("test-stack/id0"), + StackStatus: aws.String(cloudformation.StackStatusDeleteComplete), + }, + { + StackName: aws.String("test-stack"), + StackId: aws.String("test-stack/id1"), + StackStatus: aws.String(cloudformation.StackStatusCreateComplete), + EnableTerminationProtection: aws.Bool(true), + }, + }, + expectStacks: []cloudformation.Stack{ + { + StackName: aws.String("test-stack"), + StackId: aws.String("test-stack/id0"), + StackStatus: aws.String(cloudformation.StackStatusDeleteComplete), + }, + { + StackName: aws.String("test-stack"), + StackId: aws.String("test-stack/id1"), + StackStatus: aws.String(cloudformation.StackStatusUpdateComplete), + EnableTerminationProtection: aws.Bool(true), + }, + }, + }, + // Update stack and leave termination protection alone (v. 2) + { + terminationProtection: false, + stacks: []cloudformation.Stack{ + { + StackName: aws.String("test-stack"), + StackId: aws.String("test-stack/id0"), + StackStatus: aws.String(cloudformation.StackStatusDeleteComplete), + }, + { + StackName: aws.String("test-stack"), + StackId: aws.String("test-stack/id1"), + StackStatus: aws.String(cloudformation.StackStatusCreateComplete), + EnableTerminationProtection: aws.Bool(true), + }, + }, + expectStacks: []cloudformation.Stack{ + { + StackName: aws.String("test-stack"), + StackId: aws.String("test-stack/id0"), + StackStatus: aws.String(cloudformation.StackStatusDeleteComplete), + }, + { + StackName: aws.String("test-stack"), + StackId: aws.String("test-stack/id1"), + StackStatus: aws.String(cloudformation.StackStatusUpdateComplete), + EnableTerminationProtection: aws.Bool(true), + }, + }, + }, } oldCFNClient := cfnClient @@ -687,12 +796,13 @@ func TestDeploy(t *testing.T) { thisStack := c.thisStack if thisStack == (Stack{}) { thisStack = Stack{ - ParametersBody: c.parameterInput, - StackName: "test-stack", - TagsBody: c.tagInput, - TemplateBody: `{"Resources":{"SNS":{"Type":"AWS::SNS::Topic"}}}`, - CfnRoleName: c.cfnRoleName, - StackPolicyBody: c.stackPolicyInput, + ParametersBody: c.parameterInput, + StackName: "test-stack", + TagsBody: c.tagInput, + TemplateBody: `{"Resources":{"SNS":{"Type":"AWS::SNS::Topic"}}}`, + CfnRoleName: c.cfnRoleName, + StackPolicyBody: c.stackPolicyInput, + TerminationProtection: c.terminationProtection, } } diff --git a/forgelib/forge.go b/forgelib/forge.go index 4308b2f..27438a4 100644 --- a/forgelib/forge.go +++ b/forgelib/forge.go @@ -5,15 +5,16 @@ import "github.com/aws/aws-sdk-go/service/cloudformation" // Stack represents the attributes of a stack deployment, including the AWS // parameters, and local resources which represent what needs to be deployed type Stack struct { - ParametersBody string - ProjectManifest string - CfnRoleName string - StackID string - StackInfo *cloudformation.Stack - StackName string - StackPolicyBody string - TagsBody string - TemplateBody string + ParametersBody string + ProjectManifest string + CfnRoleName string + StackID string + StackInfo *cloudformation.Stack + StackName string + StackPolicyBody string + TagsBody string + TemplateBody string + TerminationProtection bool } // GetStackInfo populates the StackInfo for this object from the existing stack diff --git a/forgelib/mock_cfn_test.go b/forgelib/mock_cfn_test.go index 81f6a42..ea56a10 100644 --- a/forgelib/mock_cfn_test.go +++ b/forgelib/mock_cfn_test.go @@ -250,3 +250,30 @@ func (m mockCfn) DescribeStackEventsPages(input *cloudformation.DescribeStackEve } return nil } + +func (m mockCfn) UpdateTerminationProtection(input *cloudformation.UpdateTerminationProtectionInput) (*cloudformation.UpdateTerminationProtectionOutput, error) { + output := cloudformation.UpdateTerminationProtectionOutput{} + // For each existing stack, match against the stack ID first, then the stack + // name. If found and the stack is in a good state, set values against it + // and return + for i := 0; i < len(*m.stacks); i++ { + if s := *input.StackName; s == *(*m.stacks)[i].StackId || + s == *(*m.stacks)[i].StackName { + switch *(*m.stacks)[i].StackStatus { + case cloudformation.StackStatusCreateComplete, + cloudformation.StackStatusUpdateComplete, + cloudformation.StackStatusUpdateRollbackComplete: + + (*m.stacks)[i].EnableTerminationProtection = input.EnableTerminationProtection + + output.StackId = (*m.stacks)[i].StackId + return &output, nil + } + } + } + return &output, awserr.New( + "ValidationError", + fmt.Sprintf("Stack with id %s does not exist", *input.StackName), + nil, + ) +}