// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package cloudformation import ( "errors" "fmt" "strings" "testing" "time" "github.com/aws/aws-sdk-go/aws" awscfn "github.com/aws/aws-sdk-go/service/cloudformation" "github.com/aws/aws-sdk-go/service/cloudformation/cloudformationiface" "github.com/aws/copilot-cli/internal/pkg/aws/cloudformation" "github.com/aws/copilot-cli/internal/pkg/aws/cloudformation/cloudformationtest" "github.com/aws/copilot-cli/internal/pkg/aws/cloudformation/stackset" "github.com/aws/copilot-cli/internal/pkg/config" "github.com/aws/copilot-cli/internal/pkg/deploy" "github.com/aws/copilot-cli/internal/pkg/deploy/cloudformation/mocks" "github.com/aws/copilot-cli/internal/pkg/deploy/cloudformation/stack" "github.com/golang/mock/gomock" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" ) func TestCloudFormation_DeployApp(t *testing.T) { mockApp := &deploy.CreateAppInput{ Name: "testapp", AccountID: "1234", Version: "v1.29.0", } testCases := map[string]struct { mockStack func(ctrl *gomock.Controller) cfnClient mockStackSet func(t *testing.T, ctrl *gomock.Controller) stackSetClient region string want error }{ "should return an error if infrastructure roles stack fails": { mockStack: func(ctrl *gomock.Controller) cfnClient { m := mocks.NewMockcfnClient(ctrl) m.EXPECT().Create(gomock.Any()).Return("", errors.New("error creating stack")) m.EXPECT().ErrorEvents(gomock.Any()).Return(nil, nil) // No additional error descriptions. return m }, mockStackSet: func(t *testing.T, ctrl *gomock.Controller) stackSetClient { return nil }, want: errors.New("error creating stack"), }, "should return a wrapped error if region is invalid when populating the admin role arn": { region: "bad-region", mockStack: func(ctrl *gomock.Controller) cfnClient { m := mocks.NewMockcfnClient(ctrl) m.EXPECT().Create(gomock.Any()).Return("", &cloudformation.ErrStackAlreadyExists{}) return m }, mockStackSet: func(t *testing.T, ctrl *gomock.Controller) stackSetClient { return nil }, want: fmt.Errorf("get stack set administrator role arn: find the partition for region bad-region"), }, "should return nil if there are no updates": { region: "us-west-2", mockStack: func(ctrl *gomock.Controller) cfnClient { m := mocks.NewMockcfnClient(ctrl) m.EXPECT().Create(gomock.Any()).Return("", &cloudformation.ErrStackAlreadyExists{}) return m }, mockStackSet: func(t *testing.T, ctrl *gomock.Controller) stackSetClient { m := mocks.NewMockstackSetClient(ctrl) m.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(nil) return m }, }, "should return nil if infrastructure roles stackset created for the first time": { region: "us-west-2", mockStack: func(ctrl *gomock.Controller) cfnClient { m := mocks.NewMockcfnClient(ctrl) m.EXPECT().Create(gomock.Any()).Return("", nil) return m }, mockStackSet: func(t *testing.T, ctrl *gomock.Controller) stackSetClient { m := mocks.NewMockstackSetClient(ctrl) m.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(nil). Do(func(name, _ string, _ ...stackset.CreateOrUpdateOption) { require.Equal(t, "testapp-infrastructure", name) }) return m }, }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { // GIVEN ctrl := gomock.NewController(t) defer ctrl.Finish() cf := CloudFormation{ cfnClient: tc.mockStack(ctrl), appStackSet: tc.mockStackSet(t, ctrl), region: tc.region, console: new(discardFile), } // WHEN got := cf.DeployApp(mockApp) // THEN if tc.want != nil { require.EqualError(t, tc.want, got.Error()) } else { require.NoError(t, got) } }) } } func TestCloudFormation_UpgradeApplication(t *testing.T) { testCases := map[string]struct { mockDeployer func(t *testing.T, ctrl *gomock.Controller) *CloudFormation wantedErr error }{ "error if fail to get existing application infrastructure stack": { mockDeployer: func(t *testing.T, ctrl *gomock.Controller) *CloudFormation { return &CloudFormation{ cfnClient: &cloudformationtest.Double{ DescribeFn: func(string) (*cloudformation.StackDescription, error) { return nil, errors.New("some error") }, }, } }, wantedErr: fmt.Errorf("get existing application infrastructure stack: some error"), }, "error if fail to update app stack": { mockDeployer: func(t *testing.T, ctrl *gomock.Controller) *CloudFormation { return &CloudFormation{ cfnClient: &cloudformationtest.Double{ DescribeFn: func(string) (*cloudformation.StackDescription, error) { return &cloudformation.StackDescription{}, nil }, UpdateFn: func(*cloudformation.Stack) (string, error) { return "", fmt.Errorf("some error") }, }, renderStackSet: func(input renderStackSetInput) error { return nil }, } }, wantedErr: fmt.Errorf(`upgrade stack "phonetool-infrastructure-roles": some error`), }, // TODO test tags manually "error if fail to describe app change set": { mockDeployer: func(t *testing.T, ctrl *gomock.Controller) *CloudFormation { return &CloudFormation{ cfnClient: &cloudformationtest.Double{ DescribeFn: func(string) (*cloudformation.StackDescription, error) { return &cloudformation.StackDescription{}, nil }, UpdateFn: func(*cloudformation.Stack) (string, error) { return "", nil }, DescribeChangeSetFn: func(changeSetID, stackName string) (*cloudformation.ChangeSetDescription, error) { return nil, errors.New("some error") }, }, } }, wantedErr: fmt.Errorf(`upgrade stack "phonetool-infrastructure-roles": some error`), }, "error if fail to get app change set template": { mockDeployer: func(t *testing.T, ctrl *gomock.Controller) *CloudFormation { return &CloudFormation{ cfnClient: &cloudformationtest.Double{ DescribeFn: func(string) (*cloudformation.StackDescription, error) { return &cloudformation.StackDescription{}, nil }, UpdateFn: func(*cloudformation.Stack) (string, error) { return "", nil }, DescribeChangeSetFn: func(changeSetID, stackName string) (*cloudformation.ChangeSetDescription, error) { return &cloudformation.ChangeSetDescription{}, nil }, TemplateBodyFromChangeSetFn: func(changeSetID, stackName string) (string, error) { return "", errors.New("some error") }, }, } }, wantedErr: fmt.Errorf(`upgrade stack "phonetool-infrastructure-roles": some error`), }, "error if fail to wait until stack set last operation complete": { mockDeployer: func(t *testing.T, ctrl *gomock.Controller) *CloudFormation { mockAppStackSet := mocks.NewMockstackSetClient(ctrl) mockAppStackSet.EXPECT().WaitForStackSetLastOperationComplete("phonetool-infrastructure").Return(errors.New("some error")) return &CloudFormation{ console: mockFileWriter{Writer: &strings.Builder{}}, cfnClient: &cloudformationtest.Double{ DescribeFn: func(string) (*cloudformation.StackDescription, error) { return &cloudformation.StackDescription{}, nil }, UpdateFn: func(*cloudformation.Stack) (string, error) { return "", nil }, DescribeChangeSetFn: func(changeSetID, stackName string) (*cloudformation.ChangeSetDescription, error) { return &cloudformation.ChangeSetDescription{}, nil }, TemplateBodyFromChangeSetFn: func(changeSetID, stackName string) (string, error) { return ``, nil }, DescribeStackEventsFn: func(input *awscfn.DescribeStackEventsInput) (*awscfn.DescribeStackEventsOutput, error) { // just finish the renderer on the first Describe call return &awscfn.DescribeStackEventsOutput{ StackEvents: []*awscfn.StackEvent{ { Timestamp: aws.Time(time.Now().Add(1 * time.Hour)), LogicalResourceId: aws.String("phonetool-infrastructure-roles"), ResourceStatus: aws.String(awscfn.StackStatusUpdateComplete), }, }, }, nil }, }, appStackSet: mockAppStackSet, } }, wantedErr: fmt.Errorf(`wait for stack set phonetool-infrastructure last operation complete: some error`), }, "success": { mockDeployer: func(t *testing.T, ctrl *gomock.Controller) *CloudFormation { mockAppStackSet := mocks.NewMockstackSetClient(ctrl) mockAppStackSet.EXPECT().WaitForStackSetLastOperationComplete("phonetool-infrastructure").Return(nil) mockAppStackSet.EXPECT().Describe("phonetool-infrastructure").Return(stackset.Description{}, nil) mockAppStackSet.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return("", nil) return &CloudFormation{ console: mockFileWriter{Writer: &strings.Builder{}}, cfnClient: &cloudformationtest.Double{ DescribeFn: func(string) (*cloudformation.StackDescription, error) { return &cloudformation.StackDescription{}, nil }, UpdateFn: func(*cloudformation.Stack) (string, error) { return "", nil }, DescribeChangeSetFn: func(changeSetID, stackName string) (*cloudformation.ChangeSetDescription, error) { return &cloudformation.ChangeSetDescription{}, nil }, TemplateBodyFromChangeSetFn: func(changeSetID, stackName string) (string, error) { return ``, nil }, DescribeStackEventsFn: func(input *awscfn.DescribeStackEventsInput) (*awscfn.DescribeStackEventsOutput, error) { return &awscfn.DescribeStackEventsOutput{ StackEvents: []*awscfn.StackEvent{ { Timestamp: aws.Time(time.Now().Add(1 * time.Hour)), LogicalResourceId: aws.String("phonetool-infrastructure-roles"), ResourceStatus: aws.String(awscfn.StackStatusUpdateComplete), }, }, }, nil }, }, appStackSet: mockAppStackSet, region: "us-west-2", renderStackSet: func(input renderStackSetInput) error { _, err := input.createOpFn() return err }, } }, }, "success with multiple tries and waitings": { mockDeployer: func(t *testing.T, ctrl *gomock.Controller) *CloudFormation { mockAppStackSet := mocks.NewMockstackSetClient(ctrl) mockAppStackSet.EXPECT().WaitForStackSetLastOperationComplete("phonetool-infrastructure").Return(nil) mockAppStackSet.EXPECT().Describe("phonetool-infrastructure").Return(stackset.Description{}, nil) mockAppStackSet.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return("", &stackset.ErrStackSetOutOfDate{}) mockAppStackSet.EXPECT().WaitForStackSetLastOperationComplete("phonetool-infrastructure").Return(nil) mockAppStackSet.EXPECT().Describe("phonetool-infrastructure").Return(stackset.Description{}, nil) mockAppStackSet.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return("", nil) return &CloudFormation{ console: mockFileWriter{Writer: &strings.Builder{}}, cfnClient: &cloudformationtest.Double{ DescribeFn: func(string) (*cloudformation.StackDescription, error) { return &cloudformation.StackDescription{}, nil }, UpdateFn: func(*cloudformation.Stack) (string, error) { return "", nil }, DescribeChangeSetFn: func(changeSetID, stackName string) (*cloudformation.ChangeSetDescription, error) { return &cloudformation.ChangeSetDescription{}, nil }, TemplateBodyFromChangeSetFn: func(changeSetID, stackName string) (string, error) { return ``, nil }, DescribeStackEventsFn: func(input *awscfn.DescribeStackEventsInput) (*awscfn.DescribeStackEventsOutput, error) { return &awscfn.DescribeStackEventsOutput{ StackEvents: []*awscfn.StackEvent{ { Timestamp: aws.Time(time.Now().Add(1 * time.Hour)), LogicalResourceId: aws.String("phonetool-infrastructure-roles"), ResourceStatus: aws.String(awscfn.StackStatusUpdateComplete), }, }, }, nil }, }, appStackSet: mockAppStackSet, region: "us-west-2", renderStackSet: func(input renderStackSetInput) error { _, err := input.createOpFn() return err }, } }, }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { // GIVEN ctrl := gomock.NewController(t) defer ctrl.Finish() cf := tc.mockDeployer(t, ctrl) // WHEN err := cf.UpgradeApplication(&deploy.CreateAppInput{ Name: "phonetool", }) // THEN if tc.wantedErr != nil { require.EqualError(t, err, tc.wantedErr.Error()) } else { require.NoError(t, err) } }) } } func TestCloudFormation_AddEnvToApp(t *testing.T) { mockApp := config.Application{ Name: "testapp", AccountID: "1234", } testCases := map[string]struct { mockStackSet func(t *testing.T, ctrl *gomock.Controller) stackSetClient app *config.Application env *config.Environment want error }{ "with no existing deployments and adding an env": { app: &mockApp, env: &config.Environment{Name: "test", AccountID: "1234", Region: "us-west-2"}, mockStackSet: func(t *testing.T, ctrl *gomock.Controller) stackSetClient { m := mocks.NewMockstackSetClient(ctrl) body, err := yaml.Marshal(stack.DeployedAppMetadata{}) require.NoError(t, err) m.EXPECT().Describe(gomock.Any()).Return(stackset.Description{ Template: string(body), }, nil) m.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return("", nil). Do(func(_, _ string, ops ...stackset.CreateOrUpdateOption) { actual := &awscfn.UpdateStackSetInput{} ops[0](actual) wanted := &awscfn.UpdateStackSetInput{} stackset.WithOperationID("1")(wanted) require.Equal(t, actual, wanted) }) m.EXPECT().InstanceSummaries(gomock.Any()).Return([]stackset.InstanceSummary{}, nil) m.EXPECT().CreateInstances(gomock.Any(), []string{"1234"}, []string{"us-west-2"}).Return("", nil) return m }, }, "with no new account ID added": { app: &mockApp, env: &config.Environment{Name: "test", AccountID: "1234", Region: "us-west-2"}, mockStackSet: func(t *testing.T, ctrl *gomock.Controller) stackSetClient { m := mocks.NewMockstackSetClient(ctrl) body, err := yaml.Marshal(stack.DeployedAppMetadata{ Metadata: stack.AppResources{ AppResourcesConfig: stack.AppResourcesConfig{ Accounts: []string{"1234"}, }, }, }) require.NoError(t, err) m.EXPECT().Describe(gomock.Any()).Return(stackset.Description{ Template: string(body), }, nil) m.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return("", nil) m.EXPECT().InstanceSummaries(gomock.Any()).Return([]stackset.InstanceSummary{}, nil) m.EXPECT().CreateInstances(gomock.Any(), []string{"1234"}, []string{"us-west-2"}).Return("", nil) return m }, }, "with existing stack instances in same region but different account (no new stack instances, but update stackset)": { app: &mockApp, env: &config.Environment{Name: "test", AccountID: "1234", Region: "us-west-2"}, mockStackSet: func(t *testing.T, ctrl *gomock.Controller) stackSetClient { m := mocks.NewMockstackSetClient(ctrl) body, err := yaml.Marshal(stack.DeployedAppMetadata{ Metadata: stack.AppResources{ AppResourcesConfig: stack.AppResourcesConfig{ Accounts: []string{"1234"}, Version: 1, }, }, }) require.NoError(t, err) m.EXPECT().Describe(gomock.Any()).Return(stackset.Description{ Template: string(body), }, nil) m.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return("", nil). Do(func(_, _ string, ops ...stackset.CreateOrUpdateOption) { actual := &awscfn.UpdateStackSetInput{} ops[0](actual) wanted := &awscfn.UpdateStackSetInput{} stackset.WithOperationID("2")(wanted) require.Equal(t, actual, wanted) }) m.EXPECT().InstanceSummaries(gomock.Any()).Return([]stackset.InstanceSummary{ { Region: "us-west-2", Account: "1234", }, }, nil) m.EXPECT().CreateInstances(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) return m }, }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { // GIVEN ctrl := gomock.NewController(t) defer ctrl.Finish() cf := CloudFormation{ appStackSet: tc.mockStackSet(t, ctrl), region: "us-west-2", renderStackSet: func(input renderStackSetInput) error { _, err := input.createOpFn() return err }, } got := cf.AddEnvToApp(&AddEnvToAppOpts{ App: tc.app, EnvName: tc.env.Name, EnvAccountID: tc.env.AccountID, EnvRegion: tc.env.Region, }) if tc.want != nil { require.EqualError(t, got, tc.want.Error()) } else { require.NoError(t, got) } }) } } func TestCloudFormation_AddPipelineResourcesToApp(t *testing.T) { mockApp := config.Application{ Name: "testapp", AccountID: "1234", } testCases := map[string]struct { app *config.Application mockStackSet func(t *testing.T, ctrl *gomock.Controller) stackSetClient getRegionFromClient func(client cloudformationiface.CloudFormationAPI) (string, error) expectedErr error }{ "with no existing account nor environment, add pipeline supporting resources": { app: &mockApp, mockStackSet: func(t *testing.T, ctrl *gomock.Controller) stackSetClient { m := mocks.NewMockstackSetClient(ctrl) m.EXPECT().InstanceSummaries(gomock.Any()).Return([]stackset.InstanceSummary{}, nil) body, err := yaml.Marshal(stack.DeployedAppMetadata{}) require.NoError(t, err) m.EXPECT().Describe(gomock.Any()).Return(stackset.Description{ Template: string(body), }, nil) m.EXPECT().CreateInstances(gomock.Any(), []string{"1234"}, []string{"us-west-2"}).Return("1", nil) return m }, getRegionFromClient: func(client cloudformationiface.CloudFormationAPI) (string, error) { return "us-west-2", nil }, }, "with existing account and existing environment in a region, should not add pipeline supporting resources": { app: &mockApp, mockStackSet: func(t *testing.T, ctrl *gomock.Controller) stackSetClient { m := mocks.NewMockstackSetClient(ctrl) m.EXPECT().InstanceSummaries(gomock.Any()).Return([]stackset.InstanceSummary{ { Region: "us-west-2", Account: mockApp.AccountID, }, }, nil) body, err := yaml.Marshal(stack.DeployedAppMetadata{}) require.NoError(t, err) m.EXPECT().Describe(gomock.Any()).Return(stackset.Description{ Template: string(body), }, nil) m.EXPECT().CreateInstances(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) return m }, getRegionFromClient: func(client cloudformationiface.CloudFormationAPI) (string, error) { return "us-west-2", nil }, }, } actual := getRegionFromClient // FIXME refactor using defer func for name, tc := range testCases { t.Run(name, func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() cf := CloudFormation{ appStackSet: tc.mockStackSet(t, ctrl), renderStackSet: func(input renderStackSetInput) error { _, err := input.createOpFn() return err }, } getRegionFromClient = tc.getRegionFromClient got := cf.AddPipelineResourcesToApp(tc.app, "us-west-2") if tc.expectedErr != nil { require.EqualError(t, got, tc.expectedErr.Error()) } else { require.NoError(t, got) } }) } getRegionFromClient = actual } func TestCloudFormation_AddServiceToApp(t *testing.T) { mockApp := config.Application{ Name: "testapp", AccountID: "1234", } testCases := map[string]struct { app *config.Application svcName string mockStackSet func(t *testing.T, ctrl *gomock.Controller) stackSetClient want error }{ "with no existing deployments and adding a service": { app: &mockApp, svcName: "TestSvc", mockStackSet: func(t *testing.T, ctrl *gomock.Controller) stackSetClient { m := mocks.NewMockstackSetClient(ctrl) m.EXPECT().Describe(gomock.Any()).Return(stackset.Description{ Template: `Metadata: Version: Services: []`, }, nil) m.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return("", nil). Do(func(_, template string, _ ...stackset.CreateOrUpdateOption) { configToDeploy, err := stack.AppConfigFrom(&template) require.NoError(t, err) require.ElementsMatch(t, []stack.AppResourcesWorkload{{Name: "TestSvc", WithECR: true}}, configToDeploy.Workloads) require.Empty(t, configToDeploy.Accounts, "there should be no new accounts to deploy") require.Equal(t, 1, configToDeploy.Version) }) return m }, }, "with new app to existing app with existing services": { app: &mockApp, svcName: "test", mockStackSet: func(t *testing.T, ctrl *gomock.Controller) stackSetClient { m := mocks.NewMockstackSetClient(ctrl) m.EXPECT().Describe(gomock.Any()).Return(stackset.Description{ Template: `Metadata: Version: 1 Services: - firsttest`, }, nil) m.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return("", nil). Do(func(_, template string, _ ...stackset.CreateOrUpdateOption) { configToDeploy, err := stack.AppConfigFrom(&template) require.NoError(t, err) require.ElementsMatch(t, []stack.AppResourcesWorkload{ {Name: "test", WithECR: true}, {Name: "firsttest", WithECR: true}, }, configToDeploy.Workloads) require.Empty(t, configToDeploy.Accounts, "there should be no new accounts to deploy") require.Equal(t, 2, configToDeploy.Version) }) return m }, }, "with existing service to existing app with existing services": { app: &mockApp, svcName: "test", mockStackSet: func(t *testing.T, ctrl *gomock.Controller) stackSetClient { m := mocks.NewMockstackSetClient(ctrl) m.EXPECT().Describe(gomock.Any()).Return(stackset.Description{ Template: `Metadata: Version: 1 Services: - test`, }, nil) m.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Times(0) return m }, }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() cf := CloudFormation{ appStackSet: tc.mockStackSet(t, ctrl), region: "us-west-2", renderStackSet: func(input renderStackSetInput) error { _, err := input.createOpFn() return err }, } got := cf.AddServiceToApp(tc.app, tc.svcName) if tc.want != nil { require.EqualError(t, got, tc.want.Error()) } else { require.NoError(t, got) } }) } } func TestCloudFormation_RemoveServiceFromApp(t *testing.T) { mockApp := &config.Application{ Name: "testapp", AccountID: "1234", } tests := map[string]struct { service string mockStackSet func(t *testing.T, ctrl *gomock.Controller) stackSetClient want error }{ "should remove input service from the stack set": { service: "test", mockStackSet: func(t *testing.T, ctrl *gomock.Controller) stackSetClient { m := mocks.NewMockstackSetClient(ctrl) m.EXPECT().Describe(gomock.Any()).Return(stackset.Description{ Template: `Metadata: Version: 1 Services: - firsttest - test`, }, nil) m.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return("", nil). Do(func(_, template string, opts ...stackset.CreateOrUpdateOption) { configToDeploy, err := stack.AppConfigFrom(&template) require.NoError(t, err) require.ElementsMatch(t, []stack.AppResourcesWorkload{{Name: "firsttest", WithECR: true}}, configToDeploy.Workloads) require.Empty(t, configToDeploy.Accounts, "config account list should be empty") require.Equal(t, 2, configToDeploy.Version) require.Equal(t, 5, len(opts)) }) return m }, }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() cf := CloudFormation{ appStackSet: tc.mockStackSet(t, ctrl), region: "us-west-2", renderStackSet: func(input renderStackSetInput) error { _, err := input.createOpFn() return err }, } got := cf.RemoveServiceFromApp(mockApp, tc.service) require.Equal(t, tc.want, got) }) } } func TestCloudFormation_GetRegionalAppResources(t *testing.T) { mockApp := config.Application{Name: "app", AccountID: "12345"} testCases := map[string]struct { createRegionalMockClient func(ctrl *gomock.Controller) cfnClient mockStackSet func(t *testing.T, ctrl *gomock.Controller) stackSetClient wantedResource stack.AppRegionalResources want error }{ "should describe stack instances and convert to AppRegionalResources": { wantedResource: stack.AppRegionalResources{ KMSKeyARN: "arn:aws:kms:us-west-2:01234567890:key/0000", S3Bucket: "tests3-bucket-us-west-2", Region: "us-east-9", RepositoryURLs: map[string]string{"phonetool-svc": "123.dkr.ecr.us-west-2.amazonaws.com/phonetool-svc"}, }, createRegionalMockClient: func(ctrl *gomock.Controller) cfnClient { m := mocks.NewMockcfnClient(ctrl) m.EXPECT().Describe("cross-region-stack").Return(mockValidAppResourceStack(), nil) return m }, mockStackSet: func(t *testing.T, ctrl *gomock.Controller) stackSetClient { m := mocks.NewMockstackSetClient(ctrl) m.EXPECT().InstanceSummaries(gomock.Any(), gomock.Any()). Return([]stackset.InstanceSummary{ { StackID: "cross-region-stack", Region: "us-east-9", }, }, nil). Do(func(_ string, opt stackset.InstanceSummariesOption) { wanted := &awscfn.ListStackInstancesInput{ StackInstanceAccount: aws.String("12345"), } actual := &awscfn.ListStackInstancesInput{} opt(actual) require.Equal(t, wanted, actual) }) return m }, }, "should propagate describe errors": { want: fmt.Errorf("describing application resources: getting outputs for stack cross-region-stack in region us-east-9: error calling cloudformation"), createRegionalMockClient: func(ctrl *gomock.Controller) cfnClient { m := mocks.NewMockcfnClient(ctrl) m.EXPECT().Describe("cross-region-stack").Return(nil, errors.New("error calling cloudformation")) return m }, mockStackSet: func(t *testing.T, ctrl *gomock.Controller) stackSetClient { m := mocks.NewMockstackSetClient(ctrl) m.EXPECT().InstanceSummaries(gomock.Any(), gomock.Any()).Return([]stackset.InstanceSummary{ { StackID: "cross-region-stack", Region: "us-east-9", }, }, nil) return m }, }, "should propagate list stack instances errors": { want: fmt.Errorf("describing application resources: error"), mockStackSet: func(t *testing.T, ctrl *gomock.Controller) stackSetClient { m := mocks.NewMockstackSetClient(ctrl) m.EXPECT().InstanceSummaries(gomock.Any(), gomock.Any()).Return(nil, errors.New("error")) return m }, }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { // GIVEN ctrl := gomock.NewController(t) defer ctrl.Finish() cf := CloudFormation{ regionalClient: func(region string) cfnClient { return tc.createRegionalMockClient(ctrl) }, appStackSet: tc.mockStackSet(t, ctrl), } // WHEN got, err := cf.GetRegionalAppResources(&mockApp) // THEN if tc.want != nil { require.Error(t, err) require.EqualError(t, err, tc.want.Error()) } else { require.True(t, len(got) == 1, "Expected only one resource") // Assert that the application resources are the same. require.Equal(t, tc.wantedResource, *got[0]) } }) } } func TestCloudFormation_GetAppResourcesByRegion(t *testing.T) { mockApp := config.Application{Name: "app", AccountID: "12345"} testCases := map[string]struct { createRegionalMockClient func(ctrl *gomock.Controller) cfnClient mockStackSet func(t *testing.T, ctrl *gomock.Controller) stackSetClient wantedResource stack.AppRegionalResources region string want error }{ "should describe stack instances and convert to AppRegionalResources": { wantedResource: stack.AppRegionalResources{ KMSKeyARN: "arn:aws:kms:us-west-2:01234567890:key/0000", S3Bucket: "tests3-bucket-us-west-2", Region: "us-east-9", RepositoryURLs: map[string]string{"phonetool-svc": "123.dkr.ecr.us-west-2.amazonaws.com/phonetool-svc"}, }, region: "us-east-9", createRegionalMockClient: func(ctrl *gomock.Controller) cfnClient { m := mocks.NewMockcfnClient(ctrl) m.EXPECT().Describe("cross-region-stack").Return(mockValidAppResourceStack(), nil) return m }, mockStackSet: func(t *testing.T, ctrl *gomock.Controller) stackSetClient { m := mocks.NewMockstackSetClient(ctrl) m.EXPECT().InstanceSummaries(gomock.Any(), gomock.Any(), gomock.Any()). Return([]stackset.InstanceSummary{ { StackID: "cross-region-stack", Region: "us-east-9", }, }, nil). Do(func(_ string, opts ...stackset.InstanceSummariesOption) { wanted := &awscfn.ListStackInstancesInput{ StackInstanceAccount: aws.String("12345"), StackInstanceRegion: aws.String("us-east-9"), } actual := &awscfn.ListStackInstancesInput{} optAcc, optRegion := opts[0], opts[1] optAcc(actual) optRegion(actual) require.Equal(t, wanted, actual) }) return m }, }, "should error when resources are found": { want: fmt.Errorf("no regional resources for application app in region us-east-9 found"), region: "us-east-9", mockStackSet: func(t *testing.T, ctrl *gomock.Controller) stackSetClient { m := mocks.NewMockstackSetClient(ctrl) m.EXPECT().InstanceSummaries(gomock.Any(), gomock.Any(), gomock.Any()).Return([]stackset.InstanceSummary{}, nil) return m }, }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { // GIVEN ctrl := gomock.NewController(t) defer ctrl.Finish() cf := CloudFormation{ regionalClient: func(region string) cfnClient { return tc.createRegionalMockClient(ctrl) }, appStackSet: tc.mockStackSet(t, ctrl), } // WHEN got, err := cf.GetAppResourcesByRegion(&mockApp, tc.region) // THEN if tc.want != nil { require.Error(t, err) require.EqualError(t, err, tc.want.Error()) } else { require.NotNil(t, got) // Assert that the application resources are the same. require.Equal(t, tc.wantedResource, *got) } }) } } func TestCloudFormation_DelegateDNSPermissions(t *testing.T) { testCases := map[string]struct { app *config.Application accountID string createMock func(ctrl *gomock.Controller) cfnClient want error }{ "Calls Update Stack": { app: &config.Application{ AccountID: "1234", Name: "app", Domain: "amazon.com", }, createMock: func(ctrl *gomock.Controller) cfnClient { m := mocks.NewMockcfnClient(ctrl) m.EXPECT().Describe(gomock.Any()).Return(mockAppRolesStack("stackname", map[string]string{ "AppDNSDelegatedAccounts": "1234", }), nil) m.EXPECT().UpdateAndWait(gomock.Any()).Return(nil) return m }, }, "Returns error from Describe Stack": { app: &config.Application{ AccountID: "1234", Name: "app", Domain: "amazon.com", }, want: fmt.Errorf("getting existing application infrastructure stack: error"), createMock: func(ctrl *gomock.Controller) cfnClient { m := mocks.NewMockcfnClient(ctrl) m.EXPECT().Describe(gomock.Any()).Return(nil, errors.New("error")) return m }, }, "Returns nil if there are no changeset updates from deployChangeSet": { app: &config.Application{ AccountID: "1234", Name: "app", Domain: "amazon.com", }, want: nil, createMock: func(ctrl *gomock.Controller) cfnClient { m := mocks.NewMockcfnClient(ctrl) m.EXPECT().Describe(gomock.Any()).Return(mockAppRolesStack("stackname", map[string]string{ "AppDNSDelegatedAccounts": "1234", }), nil) m.EXPECT().UpdateAndWait(gomock.Any()).Return(&cloudformation.ErrChangeSetEmpty{}) return m }, }, "Returns error from Update Stack": { app: &config.Application{ AccountID: "1234", Name: "app", Domain: "amazon.com", }, want: fmt.Errorf("updating application to allow DNS delegation: error"), createMock: func(ctrl *gomock.Controller) cfnClient { m := mocks.NewMockcfnClient(ctrl) m.EXPECT().Describe(gomock.Any()).Return(mockAppRolesStack("stackname", map[string]string{ "AppDNSDelegatedAccounts": "1234", }), nil) m.EXPECT().UpdateAndWait(gomock.Any()).Return(errors.New("error")) return m }, }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { // GIVEN ctrl := gomock.NewController(t) defer ctrl.Finish() cf := CloudFormation{ cfnClient: tc.createMock(ctrl), } // WHEN got := cf.DelegateDNSPermissions(tc.app, tc.accountID) // THEN if tc.want != nil { require.EqualError(t, tc.want, got.Error()) } else { require.NoError(t, got) } }) } } func mockValidAppResourceStack() *cloudformation.StackDescription { return mockAppResourceStack("stack", map[string]string{ "KMSKeyARN": "arn:aws:kms:us-west-2:01234567890:key/0000", "PipelineBucket": "tests3-bucket-us-west-2", "ECRRepophonetoolDASHsvc": "arn:aws:ecr:us-west-2:123:repository/phonetool-svc", }) } func mockAppResourceStack(stackArn string, outputs map[string]string) *cloudformation.StackDescription { outputList := []*awscfn.Output{} for key, val := range outputs { outputList = append(outputList, &awscfn.Output{ OutputKey: aws.String(key), OutputValue: aws.String(val), }) } return &cloudformation.StackDescription{ StackId: aws.String(stackArn), Outputs: outputList, } } func mockAppRolesStack(stackArn string, parameters map[string]string) *cloudformation.StackDescription { parametersList := []*awscfn.Parameter{} for key, val := range parameters { parametersList = append(parametersList, &awscfn.Parameter{ ParameterKey: aws.String(key), ParameterValue: aws.String(val), }) } return &cloudformation.StackDescription{ StackId: aws.String(stackArn), StackStatus: aws.String("UPDATE_COMPLETE"), Parameters: parametersList, } } func TestCloudFormation_DeleteApp(t *testing.T) { tests := map[string]struct { appName string createMock func(ctrl *gomock.Controller) cfnClient mockStackSet func(ctrl *gomock.Controller) stackSetClient want error }{ "should delete stackset and then infrastructure roles": { appName: "testApp", createMock: func(ctrl *gomock.Controller) cfnClient { m := mocks.NewMockcfnClient(ctrl) m.EXPECT().TemplateBody("testApp-infrastructure-roles").Return("", nil) m.EXPECT().Describe(gomock.Any()).Return(&cloudformation.StackDescription{ StackId: aws.String("some stack"), }, nil) m.EXPECT().DeleteAndWait("testApp-infrastructure-roles").Return(&cloudformation.ErrStackNotFound{}) m.EXPECT().DescribeStackEvents(gomock.Any()).Return(&awscfn.DescribeStackEventsOutput{}, nil).AnyTimes() return m }, mockStackSet: func(ctrl *gomock.Controller) stackSetClient { m := mocks.NewMockstackSetClient(ctrl) m.EXPECT().DeleteAllInstances("testApp-infrastructure").Return("1", nil) m.EXPECT().WaitForOperation("testApp-infrastructure", "1").Return(nil) m.EXPECT().Delete("testApp-infrastructure").Return(nil) return m }, }, "should skip waiting for delete instance operation if the stack set is already deleted": { appName: "testApp", createMock: func(ctrl *gomock.Controller) cfnClient { m := mocks.NewMockcfnClient(ctrl) m.EXPECT().TemplateBody(gomock.Any()).Return("", nil) m.EXPECT().Describe(gomock.Any()).Return(&cloudformation.StackDescription{ StackId: aws.String("some stack"), }, nil) m.EXPECT().DeleteAndWait(gomock.Any()).Return(&cloudformation.ErrStackNotFound{}) m.EXPECT().DescribeStackEvents(gomock.Any()).Return(&awscfn.DescribeStackEventsOutput{}, nil).AnyTimes() return m }, mockStackSet: func(ctrl *gomock.Controller) stackSetClient { m := mocks.NewMockstackSetClient(ctrl) m.EXPECT().DeleteAllInstances(gomock.Any()).Return("", &stackset.ErrStackSetNotFound{}) m.EXPECT().Delete(gomock.Any()).Return(nil) return m }, }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { // GIVEN ctrl := gomock.NewController(t) defer ctrl.Finish() cf := CloudFormation{ cfnClient: tc.createMock(ctrl), appStackSet: tc.mockStackSet(ctrl), console: new(discardFile), } // WHEN got := cf.DeleteApp(tc.appName) // THEN require.Equal(t, tc.want, got) }) } } func TestCloudFormation_RenderStackSet(t *testing.T) { testDate := time.Date(2020, time.November, 23, 18, 0, 0, 0, time.UTC) testCases := map[string]struct { in renderStackSetInput mock func(t *testing.T, ctrl *gomock.Controller) CloudFormation wantedErr error }{ "should return the error if a stack set operation cannot be created": { in: renderStackSetInput{ hasInstanceUpdates: true, createOpFn: func() (string, error) { return "", errors.New("some error") }, now: func() time.Time { return testDate }, }, mock: func(t *testing.T, ctrl *gomock.Controller) CloudFormation { return CloudFormation{} }, wantedErr: errors.New("some error"), }, "should return a wrapped error if stack set instance streamers cannot be retrieved": { in: renderStackSetInput{ name: "demo-infra", hasInstanceUpdates: true, createOpFn: func() (string, error) { return "1", nil }, now: func() time.Time { return testDate }, }, mock: func(t *testing.T, ctrl *gomock.Controller) CloudFormation { m := mocks.NewMockstackSetClient(ctrl) m.EXPECT().InstanceSummaries(gomock.Any(), gomock.Any()).Return(nil, errors.New("some error")) return CloudFormation{ appStackSet: m, } }, wantedErr: errors.New(`retrieve stack instance streamers`), }, "cancel all goroutines if a streamer fails": { in: renderStackSetInput{ name: "demo-infra", hasInstanceUpdates: true, createOpFn: func() (string, error) { return "1", nil }, now: func() time.Time { return testDate }, }, mock: func(t *testing.T, ctrl *gomock.Controller) CloudFormation { mockStackSet := mocks.NewMockstackSetClient(ctrl) mockStackSet.EXPECT().InstanceSummaries(gomock.Any(), gomock.Any()).Return([]stackset.InstanceSummary{ { StackID: "stackset-instance-demo-infra", Account: "1111", Region: "us-west-2", Status: "RUNNING", }, }, nil) mockStackSet.EXPECT().DescribeOperation(gomock.Any(), gomock.Any()).Return(stackset.Operation{ Status: "RUNNING", }, nil).AnyTimes() mockStack := mocks.NewMockcfnClient(ctrl) mockStack.EXPECT().DescribeStackEvents(gomock.Any()). Return(nil, errors.New("some error")).AnyTimes() return CloudFormation{ appStackSet: mockStackSet, cfnClient: mockStack, regionalClient: func(_ string) cfnClient { return mockStack }, console: mockFileWriter{ Writer: new(strings.Builder), }, } }, wantedErr: errors.New(`render progress of stack set "demo-infra"`), }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { // GIVEN ctrl := gomock.NewController(t) defer ctrl.Finish() client := tc.mock(t, ctrl) // WHEN err := client.renderStackSetImpl(tc.in) // THEN if tc.wantedErr != nil { require.ErrorContains(t, err, tc.wantedErr.Error()) } else { require.NoError(t, err) } }) } } func TestCloudFormation_RemoveEnvFromApp(t *testing.T) { testCases := map[string]struct { mock func(t *testing.T, ctrl *gomock.Controller) CloudFormation inOpts RemoveEnvFromAppOpts wantedErr error }{ "failure describing stackset": { inOpts: RemoveEnvFromAppOpts{ App: &config.Application{ Name: "phonetool", AccountID: "1234", Version: "1", }, EnvToDelete: &config.Environment{ Name: "test", AccountID: "1234", Region: "us-west-2", }, Environments: []*config.Environment{ { Name: "test", AccountID: "1234", Region: "us-west-2", }, }, }, mock: func(t *testing.T, ctrl *gomock.Controller) CloudFormation { cfn := mocks.NewMockcfnClient(ctrl) appStackSet := mocks.NewMockstackSetClient(ctrl) s3 := mocks.NewMocks3Client(ctrl) ecr := mocks.NewMockimageRemover(ctrl) regionalCfn := mocks.NewMockcfnClient(ctrl) // Empty ECR and S3 appStackSet.EXPECT().InstanceSummaries("phonetool-infrastructure", gomock.Any(), gomock.Any()).Return(nil, errors.New("some error")) regionalCfn.EXPECT().Describe(gomock.Any()).Times(0) s3.EXPECT().EmptyBucket(gomock.Any()).Times(0) ecr.EXPECT().ClearRepository(gomock.Any()).Times(0) appStackSet.EXPECT().DeleteInstance(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) appStackSet.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).Times(0) cfn.EXPECT().Describe(gomock.Any()).Times(0) cfn.EXPECT().UpdateAndWait(gomock.Any()).Times(0) appStackSet.EXPECT().Describe(gomock.Any()).Times(0) appStackSet.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) return CloudFormation{ cfnClient: cfn, region: "us-east-1", appStackSet: appStackSet, dnsDelegatedAccountsForStack: func(in *awscfn.Stack) []string { return []string{"1234", "5678"} }, renderStackSet: func(in renderStackSetInput) error { _, err := in.createOpFn() return err }, regionalS3Client: func(region string) s3Client { return s3 }, regionalECRClient: func(region string) imageRemover { return ecr }, regionalClient: func(region string) cfnClient { return regionalCfn }, } }, wantedErr: errors.New("some error"), }, "success": { inOpts: RemoveEnvFromAppOpts{ App: &config.Application{ Name: "phonetool", AccountID: "1234", Version: "1", }, EnvToDelete: &config.Environment{ Name: "test", AccountID: "1234", Region: "us-west-2", }, Environments: []*config.Environment{ { Name: "test", AccountID: "1234", Region: "us-west-2", }, { Name: "prod", AccountID: "5678", Region: "us-east-2", }, }, }, mock: func(t *testing.T, ctrl *gomock.Controller) CloudFormation { cfn := mocks.NewMockcfnClient(ctrl) appStackSet := mocks.NewMockstackSetClient(ctrl) s3 := mocks.NewMocks3Client(ctrl) ecr := mocks.NewMockimageRemover(ctrl) regionalCfn := mocks.NewMockcfnClient(ctrl) // Empty ECR and S3 appStackSet.EXPECT().InstanceSummaries("phonetool-infrastructure", gomock.Any(), gomock.Any()).Return([]stackset.InstanceSummary{ { Region: "us-west-2", StackID: "some-stack", }, }, nil) regionalCfn.EXPECT().Describe("some-stack").Return(mockValidAppResourceStack(), nil) s3.EXPECT().EmptyBucket("tests3-bucket-us-west-2").Return(nil) ecr.EXPECT().ClearRepository("phonetool-svc").Return(nil) appStackSet.EXPECT().DeleteInstance("phonetool-infrastructure", "1234", "us-west-2").Return("123", nil) appStackSet.EXPECT().WaitForOperation("phonetool-infrastructure", "123").Return(nil) cfn.EXPECT().Describe(stack.NameForAppStack("phonetool")).Return(&cloudformation.StackDescription{ Parameters: []*awscfn.Parameter{ { ParameterKey: aws.String("AppDNSDelegatedAccounts"), ParameterValue: aws.String("1234,5678"), }, }, }, nil) cfn.EXPECT().UpdateAndWait(gomock.Any()) appStackSet.EXPECT().Describe("phonetool-infrastructure").Return(stackset.Description{ ID: "", Name: "phonetool-infrastructure", Template: `Metadata: TemplateVersion: 'v1.2.0' Version: 17 Workloads: - Name: ar WithECR: true Accounts: - 1234 - 5678`, }, nil) appStackSet.EXPECT().Update("phonetool-infrastructure", gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("123", nil) return CloudFormation{ cfnClient: cfn, region: "us-east-1", appStackSet: appStackSet, dnsDelegatedAccountsForStack: func(in *awscfn.Stack) []string { return []string{"1234", "5678"} }, renderStackSet: func(in renderStackSetInput) error { _, err := in.createOpFn() return err }, regionalS3Client: func(region string) s3Client { return s3 }, regionalECRClient: func(region string) imageRemover { return ecr }, regionalClient: func(region string) cfnClient { return regionalCfn }, } }, }, "skips stack redeployment if we don't need to remove account": { inOpts: RemoveEnvFromAppOpts{ App: &config.Application{ Name: "phonetool", AccountID: "1234", Version: "1", }, EnvToDelete: &config.Environment{ Name: "test", AccountID: "1234", Region: "us-west-2", }, Environments: []*config.Environment{ { Name: "test", AccountID: "1234", Region: "us-west-2", }, { Name: "prod", AccountID: "1234", Region: "us-east-2", }, }, }, mock: func(t *testing.T, ctrl *gomock.Controller) CloudFormation { cfn := mocks.NewMockcfnClient(ctrl) appStackSet := mocks.NewMockstackSetClient(ctrl) s3 := mocks.NewMocks3Client(ctrl) ecr := mocks.NewMockimageRemover(ctrl) regionalCfn := mocks.NewMockcfnClient(ctrl) // Empty ECR and S3 appStackSet.EXPECT().InstanceSummaries("phonetool-infrastructure", gomock.Any(), gomock.Any()).Return([]stackset.InstanceSummary{ { Region: "us-west-2", StackID: "some-stack", }, }, nil) regionalCfn.EXPECT().Describe("some-stack").Return(mockValidAppResourceStack(), nil) s3.EXPECT().EmptyBucket("tests3-bucket-us-west-2").Return(nil) ecr.EXPECT().ClearRepository("phonetool-svc").Return(nil) appStackSet.EXPECT().DeleteInstance("phonetool-infrastructure", "1234", "us-west-2").Return("123", nil) appStackSet.EXPECT().WaitForOperation("phonetool-infrastructure", "123").Return(nil) cfn.EXPECT().Describe(gomock.Any()).Times(0) cfn.EXPECT().UpdateAndWait(gomock.Any()).Times(0) appStackSet.EXPECT().Describe(gomock.Any()).Times(0) appStackSet.EXPECT().Update(gomock.Any(), gomock.Any()).Times(0) return CloudFormation{ cfnClient: cfn, region: "us-east-1", appStackSet: appStackSet, dnsDelegatedAccountsForStack: func(in *awscfn.Stack) []string { return []string{"5678"} }, renderStackSet: func(in renderStackSetInput) error { _, err := in.createOpFn() return err }, regionalS3Client: func(region string) s3Client { return s3 }, regionalECRClient: func(region string) imageRemover { return ecr }, regionalClient: func(region string) cfnClient { return regionalCfn }, } }, }, "skips instance delete if 'DeleteStackInstance' is false": { inOpts: RemoveEnvFromAppOpts{ App: &config.Application{ Name: "phonetool", AccountID: "1234", Version: "1", }, EnvToDelete: &config.Environment{ Name: "test", AccountID: "1234", Region: "us-west-2", }, Environments: []*config.Environment{ { Name: "test", AccountID: "1234", Region: "us-west-2", }, { Name: "prod", AccountID: "5678", Region: "us-west-2", }, }, }, mock: func(t *testing.T, ctrl *gomock.Controller) CloudFormation { cfn := mocks.NewMockcfnClient(ctrl) appStackSet := mocks.NewMockstackSetClient(ctrl) regionalCfn := mocks.NewMockcfnClient(ctrl) s3 := mocks.NewMocks3Client(ctrl) ecr := mocks.NewMockimageRemover(ctrl) // Empty ECR and S3 appStackSet.EXPECT().InstanceSummaries(gomock.Any()).Times(0) regionalCfn.EXPECT().Describe(gomock.Any()).Times(0) s3.EXPECT().EmptyBucket(gomock.Any()).Times(0) ecr.EXPECT().ClearRepository(gomock.Any()).Times(0) appStackSet.EXPECT().DeleteInstance(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) appStackSet.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).Times(0) cfn.EXPECT().Describe(stack.NameForAppStack("phonetool")).Return(&cloudformation.StackDescription{ Parameters: []*awscfn.Parameter{ { ParameterKey: aws.String("AppDNSDelegatedAccounts"), ParameterValue: aws.String("1234,5678"), }, }, }, nil) cfn.EXPECT().UpdateAndWait(gomock.Any()) appStackSet.EXPECT().Describe("phonetool-infrastructure").Return(stackset.Description{ ID: "", Name: "phonetool-infrastructure", Template: `Metadata: TemplateVersion: 'v1.2.0' Version: 17 Workloads: - Name: ar WithECR: true Accounts: - 1234 - 5678`, }, nil) appStackSet.EXPECT().Update("phonetool-infrastructure", gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("123", nil) return CloudFormation{ cfnClient: cfn, region: "us-east-1", appStackSet: appStackSet, dnsDelegatedAccountsForStack: func(in *awscfn.Stack) []string { return []string{"1234", "5678"} }, renderStackSet: func(in renderStackSetInput) error { _, err := in.createOpFn() return err }, s3Client: s3, regionalECRClient: func(region string) imageRemover { return ecr }, regionalClient: func(region string) cfnClient { return regionalCfn }, } }, }, "error deleting instance": { inOpts: RemoveEnvFromAppOpts{ App: &config.Application{ Name: "phonetool", AccountID: "1234", Version: "1", }, EnvToDelete: &config.Environment{ Name: "test", AccountID: "1234", Region: "us-west-2", }, Environments: []*config.Environment{ { Name: "test", AccountID: "1234", Region: "us-west-2", }, { Name: "prod", AccountID: "5678", Region: "us-east-2", }, }, }, wantedErr: errors.New("some error"), mock: func(t *testing.T, ctrl *gomock.Controller) CloudFormation { cfn := mocks.NewMockcfnClient(ctrl) appStackSet := mocks.NewMockstackSetClient(ctrl) s3 := mocks.NewMocks3Client(ctrl) ecr := mocks.NewMockimageRemover(ctrl) regionalCfn := mocks.NewMockcfnClient(ctrl) // Empty ECR and S3 appStackSet.EXPECT().InstanceSummaries("phonetool-infrastructure", gomock.Any(), gomock.Any()).Return([]stackset.InstanceSummary{ { Region: "us-west-2", StackID: "some-stack", }, }, nil) regionalCfn.EXPECT().Describe("some-stack").Return(mockValidAppResourceStack(), nil) s3.EXPECT().EmptyBucket("tests3-bucket-us-west-2").Return(nil) ecr.EXPECT().ClearRepository("phonetool-svc").Return(nil) // Delete stackset instance appStackSet.EXPECT().DeleteInstance("phonetool-infrastructure", "1234", "us-west-2").Return("", errors.New("some error")) appStackSet.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).Times(0) cfn.EXPECT().Describe(gomock.Any()).Times(0) cfn.EXPECT().UpdateAndWait(gomock.Any()).Times(0) appStackSet.EXPECT().Describe(gomock.Any()).Times(0) appStackSet.EXPECT().Update(gomock.Any(), gomock.Any()).Times(0) return CloudFormation{ cfnClient: cfn, region: "us-east-1", appStackSet: appStackSet, dnsDelegatedAccountsForStack: func(in *awscfn.Stack) []string { return []string{"1234", "5678"} }, renderStackSet: func(in renderStackSetInput) error { _, err := in.createOpFn() return err }, regionalClient: func(region string) cfnClient { return regionalCfn }, regionalS3Client: func(region string) s3Client { return s3 }, regionalECRClient: func(region string) imageRemover { return ecr }, } }, }, "error updating stack": { inOpts: RemoveEnvFromAppOpts{ App: &config.Application{ Name: "phonetool", AccountID: "1234", Version: "1", }, EnvToDelete: &config.Environment{ Name: "test", AccountID: "1234", Region: "us-west-2", }, Environments: []*config.Environment{ { Name: "test", AccountID: "1234", Region: "us-west-2", }, { Name: "prod", AccountID: "5678", Region: "us-east-2", }, }, }, wantedErr: errors.New("some error"), mock: func(t *testing.T, ctrl *gomock.Controller) CloudFormation { cfn := mocks.NewMockcfnClient(ctrl) appStackSet := mocks.NewMockstackSetClient(ctrl) s3 := mocks.NewMocks3Client(ctrl) ecr := mocks.NewMockimageRemover(ctrl) regionalCfn := mocks.NewMockcfnClient(ctrl) // Empty ECR and S3 appStackSet.EXPECT().InstanceSummaries("phonetool-infrastructure", gomock.Any(), gomock.Any()).Return([]stackset.InstanceSummary{ { Region: "us-west-2", StackID: "some-stack", }, }, nil) regionalCfn.EXPECT().Describe("some-stack").Return(mockValidAppResourceStack(), nil) s3.EXPECT().EmptyBucket("tests3-bucket-us-west-2").Return(nil) ecr.EXPECT().ClearRepository("phonetool-svc").Return(nil) appStackSet.EXPECT().DeleteInstance("phonetool-infrastructure", "1234", "us-west-2").Return("123", nil) appStackSet.EXPECT().WaitForOperation("phonetool-infrastructure", "123").Return(nil) cfn.EXPECT().Describe(stack.NameForAppStack("phonetool")).Return(&cloudformation.StackDescription{ Parameters: []*awscfn.Parameter{ { ParameterKey: aws.String("AppDNSDelegatedAccounts"), ParameterValue: aws.String("1234,5678"), }, }, }, nil) cfn.EXPECT().UpdateAndWait(gomock.Any()) appStackSet.EXPECT().Describe("phonetool-infrastructure").Return(stackset.Description{ ID: "", Name: "phonetool-infrastructure", Template: `Metadata: TemplateVersion: 'v1.2.0' Version: 17 Workloads: - Name: ar WithECR: true Accounts: - 1234 - 5678`, }, nil) appStackSet.EXPECT().Update("phonetool-infrastructure", gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("", errors.New("some error")) return CloudFormation{ cfnClient: cfn, region: "us-east-1", appStackSet: appStackSet, dnsDelegatedAccountsForStack: func(in *awscfn.Stack) []string { return []string{"1234", "5678"} }, renderStackSet: func(in renderStackSetInput) error { _, err := in.createOpFn() return err }, regionalClient: func(region string) cfnClient { return regionalCfn }, regionalS3Client: func(region string) s3Client { return s3 }, regionalECRClient: func(region string) imageRemover { return ecr }, } }, }, "error emptying bucket": { inOpts: RemoveEnvFromAppOpts{ App: &config.Application{ Name: "phonetool", AccountID: "1234", Version: "1", }, EnvToDelete: &config.Environment{ Name: "test", AccountID: "1234", Region: "us-west-2", }, Environments: []*config.Environment{ { Name: "test", AccountID: "1234", Region: "us-west-2", }, { Name: "prod", AccountID: "5678", Region: "us-east-2", }, }, }, wantedErr: errors.New("some error"), mock: func(t *testing.T, ctrl *gomock.Controller) CloudFormation { cfn := mocks.NewMockcfnClient(ctrl) appStackSet := mocks.NewMockstackSetClient(ctrl) s3 := mocks.NewMocks3Client(ctrl) ecr := mocks.NewMockimageRemover(ctrl) regionalCfn := mocks.NewMockcfnClient(ctrl) // Empty ECR and S3 appStackSet.EXPECT().InstanceSummaries("phonetool-infrastructure", gomock.Any(), gomock.Any()).Return([]stackset.InstanceSummary{ { Region: "us-west-2", StackID: "some-stack", }, }, nil) regionalCfn.EXPECT().Describe("some-stack").Return(mockValidAppResourceStack(), nil) s3.EXPECT().EmptyBucket("tests3-bucket-us-west-2").Return(errors.New("some error")) ecr.EXPECT().ClearRepository(gomock.Any()).Times(0) appStackSet.EXPECT().DeleteInstance(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) appStackSet.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).Times(0) cfn.EXPECT().Describe(gomock.Any()).Times(0) cfn.EXPECT().UpdateAndWait(gomock.Any()).Times(0) appStackSet.EXPECT().Describe(gomock.Any()).Times(0) appStackSet.EXPECT().Update(gomock.Any(), gomock.Any()).Times(0) return CloudFormation{ cfnClient: cfn, region: "us-east-1", appStackSet: appStackSet, dnsDelegatedAccountsForStack: func(in *awscfn.Stack) []string { return []string{"1234", "5678"} }, renderStackSet: func(in renderStackSetInput) error { _, err := in.createOpFn() return err }, regionalClient: func(region string) cfnClient { return regionalCfn }, regionalS3Client: func(region string) s3Client { return s3 }, regionalECRClient: func(region string) imageRemover { return ecr }, } }, }, "error describing regional resources": { inOpts: RemoveEnvFromAppOpts{ App: &config.Application{ Name: "phonetool", AccountID: "1234", Version: "1", }, EnvToDelete: &config.Environment{ Name: "test", AccountID: "1234", Region: "us-west-2", }, Environments: []*config.Environment{ { Name: "test", AccountID: "1234", Region: "us-west-2", }, { Name: "prod", AccountID: "5678", Region: "us-east-2", }, }, }, wantedErr: errors.New("some error"), mock: func(t *testing.T, ctrl *gomock.Controller) CloudFormation { cfn := mocks.NewMockcfnClient(ctrl) appStackSet := mocks.NewMockstackSetClient(ctrl) regionalCfn := mocks.NewMockcfnClient(ctrl) // Empty ECR and S3 appStackSet.EXPECT().InstanceSummaries("phonetool-infrastructure", gomock.Any(), gomock.Any()).Return([]stackset.InstanceSummary{ { Region: "us-west-2", StackID: "some-stack", }, }, nil) regionalCfn.EXPECT().Describe("some-stack").Return(nil, errors.New("some error")) return CloudFormation{ cfnClient: cfn, region: "us-east-1", appStackSet: appStackSet, dnsDelegatedAccountsForStack: func(in *awscfn.Stack) []string { return []string{"1234", "5678"} }, renderStackSet: func(in renderStackSetInput) error { _, err := in.createOpFn() return err }, regionalClient: func(region string) cfnClient { return regionalCfn }, } }, }, "regional stack is already deleted": { inOpts: RemoveEnvFromAppOpts{ App: &config.Application{ Name: "phonetool", AccountID: "1234", Version: "1", }, EnvToDelete: &config.Environment{ Name: "test", AccountID: "1234", Region: "us-west-2", }, Environments: []*config.Environment{ { Name: "test", AccountID: "1234", Region: "us-west-2", }, { Name: "prod", AccountID: "1234", Region: "us-east-2", }, }, }, mock: func(t *testing.T, ctrl *gomock.Controller) CloudFormation { cfn := mocks.NewMockcfnClient(ctrl) appStackSet := mocks.NewMockstackSetClient(ctrl) s3 := mocks.NewMocks3Client(ctrl) ecr := mocks.NewMockimageRemover(ctrl) regionalCfn := mocks.NewMockcfnClient(ctrl) // Empty ECR and S3 appStackSet.EXPECT().InstanceSummaries("phonetool-infrastructure", gomock.Any(), gomock.Any()).Return(nil, nil) // no instance summaries returned means no describe call and no EmptyBucket/ClearRepository call. regionalCfn.EXPECT().Describe(gomock.Any()).Times(0) s3.EXPECT().EmptyBucket(gomock.Any()).Times(0) ecr.EXPECT().ClearRepository(gomock.Any()).Times(0) appStackSet.EXPECT().DeleteInstance("phonetool-infrastructure", "1234", "us-west-2").Return("12345", nil) appStackSet.EXPECT().WaitForOperation("phonetool-infrastructure", "12345").Return(nil) cfn.EXPECT().Describe(gomock.Any()).Times(0) cfn.EXPECT().UpdateAndWait(gomock.Any()).Times(0) appStackSet.EXPECT().Describe(gomock.Any()).Times(0) appStackSet.EXPECT().Update(gomock.Any(), gomock.Any()).Times(0) return CloudFormation{ cfnClient: cfn, region: "us-east-1", appStackSet: appStackSet, dnsDelegatedAccountsForStack: func(in *awscfn.Stack) []string { return []string{"1234", "5678"} }, renderStackSet: func(in renderStackSetInput) error { _, err := in.createOpFn() return err }, regionalClient: func(region string) cfnClient { return regionalCfn }, regionalS3Client: func(region string) s3Client { return s3 }, regionalECRClient: func(region string) imageRemover { return ecr }, } }, }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { // GIVEN ctrl := gomock.NewController(t) defer ctrl.Finish() client := tc.mock(t, ctrl) // WHEN err := client.RemoveEnvFromApp(&tc.inOpts) // THEN if tc.wantedErr != nil { require.ErrorContains(t, err, tc.wantedErr.Error()) } else { require.NoError(t, err) } }) } }