diff --git a/Makefile b/Makefile index f96d6f4..bdefd47 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ MOCKGEN := go run github.com/golang/mock/mockgen@v1.6.0 .PHONY: test test: - go test -coverprofile=coverage.txt -covermode=count + go test ./... -coverprofile=coverage.txt -covermode=count test-container: docker build -t canarycage/test-container test-container push-test-container: test-container @@ -9,8 +9,11 @@ push-test-container: test-container docker push loilodev/http-server:latest version: go run cli/cage/main.go -v | cut -f 3 -d ' ' - -mocks: - mkdir -p mocks/mock_awsiface && $(MOCKGEN) -source=./awsiface/iface.go > mocks/mock_awsiface/iface.go - +mocks: mocks/mock_awsiface/iface.go mocks/mock_cage/iface.go mocks/mock_upgrade/upgrade.go +mocks/mock_awsiface/iface.go: awsiface/iface.go + $(MOCKGEN) -source=./awsiface/iface.go > mocks/mock_awsiface/iface.go +mocks/mock_cage/iface.go: cage.go + $(MOCKGEN) -source=./cage.go > mocks/mock_cage/cage.go +mocks/mock_upgrade/upgrade.go: cli/cage/upgrade/upgrade.go + $(MOCKGEN) -source=./cli/cage/upgrade/upgrade.go > mocks/mock_upgrade/upgrade.go .PHONY: mocks diff --git a/cage.go b/cage.go index 97f97b0..ee01f01 100644 --- a/cage.go +++ b/cage.go @@ -1,4 +1,3 @@ -//go:generate go run github.com/golang/mock/mockgen -source $GOFILE -destination ../mocks/mock_$GOPACKAGE/$GOFILE -package mock_$GOPACKAGE package cage import ( @@ -11,8 +10,7 @@ import ( type Cage interface { Up(ctx context.Context) (*UpResult, error) Run(ctx context.Context, input *RunInput) (*RunResult, error) - RollOut(ctx context.Context) (*RollOutResult, error) - Recreate(ctx context.Context) (*RecreateResult, error) + RollOut(ctx context.Context, input *RollOutInput) (*RollOutResult, error) } type Time interface { diff --git a/cli/cage/commands/command_test.go b/cli/cage/commands/command_test.go index 9c70127..2d776dd 100644 --- a/cli/cage/commands/command_test.go +++ b/cli/cage/commands/command_test.go @@ -32,50 +32,29 @@ func TestCommands(t *testing.T) { cmds.Up(&envars), cmds.RollOut(&envars), cmds.Run(&envars), - cmds.Recreate(&envars), } return app, cagecli } t.Run("rollout", func(t *testing.T) { t.Run("basic", func(t *testing.T) { app, cagecli := setup(t, stdinService) - cagecli.EXPECT().RollOut(gomock.Any()).Return(&cage.RollOutResult{}, nil) + cagecli.EXPECT().RollOut(gomock.Any(), gomock.Any()).Return(&cage.RollOutResult{}, nil) err := app.Run([]string{"cage", "rollout", "--region", "ap-notheast-1", "../../../fixtures"}) assert.NoError(t, err) }) t.Run("basic/ci", func(t *testing.T) { app, cagecli := setup(t, "") - cagecli.EXPECT().RollOut(gomock.Any()).Return(&cage.RollOutResult{}, nil) + cagecli.EXPECT().RollOut(gomock.Any(), gomock.Any()).Return(&cage.RollOutResult{}, nil) err := app.Run([]string{"cage", "rollout", "--region", "ap-notheast-1", "../../../fixtures"}) assert.NoError(t, err) }) t.Run("error", func(t *testing.T) { app, cagecli := setup(t, stdinService) - cagecli.EXPECT().RollOut(gomock.Any()).Return(&cage.RollOutResult{}, fmt.Errorf("error")) + cagecli.EXPECT().RollOut(gomock.Any(), gomock.Any()).Return(&cage.RollOutResult{}, fmt.Errorf("error")) err := app.Run([]string{"cage", "rollout", "--region", "ap-notheast-1", "../../../fixtures"}) assert.EqualError(t, err, "error") }) }) - t.Run("recreate", func(t *testing.T) { - t.Run("basic", func(t *testing.T) { - app, cagecli := setup(t, stdinService) - cagecli.EXPECT().Recreate(gomock.Any()).Return(&cage.RecreateResult{}, nil) - err := app.Run([]string{"cage", "recreate", "--region", "ap-notheast-1", "../../../fixtures"}) - assert.NoError(t, err) - }) - t.Run("basic/ci", func(t *testing.T) { - app, cagecli := setup(t, "") - cagecli.EXPECT().Recreate(gomock.Any()).Return(&cage.RecreateResult{}, nil) - err := app.Run([]string{"cage", "recreate", "--region", "ap-notheast-1", "../../../fixtures"}) - assert.NoError(t, err) - }) - t.Run("error", func(t *testing.T) { - app, cagecli := setup(t, stdinService) - cagecli.EXPECT().Recreate(gomock.Any()).Return(nil, fmt.Errorf("error")) - err := app.Run([]string{"cage", "recreate", "--region", "ap-notheast-1", "../../../fixtures"}) - assert.EqualError(t, err, "error") - }) - }) t.Run("up", func(t *testing.T) { t.Run("basic", func(t *testing.T) { app, cagecli := setup(t, stdinService) diff --git a/cli/cage/commands/recreate.go b/cli/cage/commands/recreate.go deleted file mode 100644 index 96b03c5..0000000 --- a/cli/cage/commands/recreate.go +++ /dev/null @@ -1,42 +0,0 @@ -package commands - -import ( - "context" - - cage "github.com/loilo-inc/canarycage" - "github.com/urfave/cli/v2" -) - -func (c *CageCommands) Recreate( - envars *cage.Envars, -) *cli.Command { - return &cli.Command{ - Name: "recreate", - Usage: "recreate ECS service with specified service/task definition", - Description: "recreate ECS service with specified service/task definition", - Args: true, - ArgsUsage: "[directory path of service.json and task-definition.json]", - Flags: []cli.Flag{ - RegionFlag(&envars.Region), - ClusterFlag(&envars.Cluster), - ServiceFlag(&envars.Service), - TaskDefinitionArnFlag(&envars.TaskDefinitionArn), - CanaryTaskIdleDurationFlag(&envars.CanaryTaskIdleDuration), - }, - Action: func(ctx *cli.Context) error { - dir, _, err := c.requireArgs(ctx, 1, 1) - if err != nil { - return err - } - cagecli, err := c.setupCage(envars, dir) - if err != nil { - return err - } - if err := c.Prompt.ConfirmService(envars); err != nil { - return err - } - _, err = cagecli.Recreate(context.Background()) - return err - }, - } -} diff --git a/cli/cage/commands/rollout.go b/cli/cage/commands/rollout.go index 41a9de6..c48b12a 100644 --- a/cli/cage/commands/rollout.go +++ b/cli/cage/commands/rollout.go @@ -11,6 +11,7 @@ import ( func (c *CageCommands) RollOut( envars *cage.Envars, ) *cli.Command { + var updateServiceConf bool return &cli.Command{ Name: "rollout", Usage: "roll out ECS service to next task definition", @@ -29,6 +30,12 @@ func (c *CageCommands) RollOut( Usage: "EC2 instance ARN for placing canary task. required only when LaunchType is EC2", Destination: &envars.CanaryInstanceArn, }, + &cli.BoolFlag{ + Name: "updateService", + EnvVars: []string{cage.UpdateServiceKey}, + Usage: "Update service configurations except for task definiton. Default is false.", + Destination: &updateServiceConf, + }, }, Action: func(ctx *cli.Context) error { dir, _, err := c.requireArgs(ctx, 1, 1) @@ -42,7 +49,7 @@ func (c *CageCommands) RollOut( if err := c.Prompt.ConfirmService(envars); err != nil { return err } - result, err := cagecli.RollOut(context.Background()) + result, err := cagecli.RollOut(context.Background(), &cage.RollOutInput{UpdateService: updateServiceConf}) if err != nil { if result.ServiceIntact { log.Errorf("🤕 failed to roll out new tasks but service '%s' is not changed", envars.Service) diff --git a/cli/cage/main.go b/cli/cage/main.go index f66c247..86bae87 100644 --- a/cli/cage/main.go +++ b/cli/cage/main.go @@ -30,7 +30,6 @@ func main() { cmds.Up(&envars), cmds.RollOut(&envars), cmds.Run(&envars), - cmds.Recreate(&envars), cmds.Upgrade(upgrade.NewUpgrader(version)), } app.Flags = []cli.Flag{ diff --git a/env.go b/env.go index 3f70e58..1e4fbfd 100644 --- a/env.go +++ b/env.go @@ -34,6 +34,7 @@ const TaskDefinitionArnKey = "CAGE_TASK_DEFINITION_ARN" const CanaryInstanceArnKey = "CAGE_CANARY_INSTANCE_ARN" const RegionKey = "CAGE_REGION" const CanaryTaskIdleDuration = "CAGE_CANARY_TASK_IDLE_DURATION" +const UpdateServiceKey = "CAGE_UPDATE_SERVIEC" func EnsureEnvars( dest *Envars, diff --git a/mocks/mock_cage/cage.go b/mocks/mock_cage/cage.go index 6a59210..e8aaa4c 100644 --- a/mocks/mock_cage/cage.go +++ b/mocks/mock_cage/cage.go @@ -1,5 +1,5 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: cage.go +// Source: ./cage.go // Package mock_cage is a generated GoMock package. package mock_cage @@ -36,34 +36,19 @@ func (m *MockCage) EXPECT() *MockCageMockRecorder { return m.recorder } -// Recreate mocks base method. -func (m *MockCage) Recreate(ctx context.Context) (*cage.RecreateResult, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Recreate", ctx) - ret0, _ := ret[0].(*cage.RecreateResult) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Recreate indicates an expected call of Recreate. -func (mr *MockCageMockRecorder) Recreate(ctx interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Recreate", reflect.TypeOf((*MockCage)(nil).Recreate), ctx) -} - // RollOut mocks base method. -func (m *MockCage) RollOut(ctx context.Context) (*cage.RollOutResult, error) { +func (m *MockCage) RollOut(ctx context.Context, input *cage.RollOutInput) (*cage.RollOutResult, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RollOut", ctx) + ret := m.ctrl.Call(m, "RollOut", ctx, input) ret0, _ := ret[0].(*cage.RollOutResult) ret1, _ := ret[1].(error) return ret0, ret1 } // RollOut indicates an expected call of RollOut. -func (mr *MockCageMockRecorder) RollOut(ctx interface{}) *gomock.Call { +func (mr *MockCageMockRecorder) RollOut(ctx, input interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RollOut", reflect.TypeOf((*MockCage)(nil).RollOut), ctx) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RollOut", reflect.TypeOf((*MockCage)(nil).RollOut), ctx, input) } // Run mocks base method. diff --git a/mocks/mock_upgrade/cage.go b/mocks/mock_upgrade/upgrade.go similarity index 97% rename from mocks/mock_upgrade/cage.go rename to mocks/mock_upgrade/upgrade.go index 9397981..452603b 100644 --- a/mocks/mock_upgrade/cage.go +++ b/mocks/mock_upgrade/upgrade.go @@ -1,5 +1,5 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: cli/cage/upgrade/upgrade.go +// Source: ./cli/cage/upgrade/upgrade.go // Package mock_upgrade is a generated GoMock package. package mock_upgrade diff --git a/recreate.go b/recreate.go deleted file mode 100644 index 3d6bab4..0000000 --- a/recreate.go +++ /dev/null @@ -1,139 +0,0 @@ -package cage - -import ( - "context" - "fmt" - - "github.com/apex/log" - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/ecs" - ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" - "golang.org/x/xerrors" -) - -type RecreateResult struct { - Service *ecstypes.Service - TaskDefinition *ecstypes.TaskDefinition -} - -func (c *cage) Recreate(ctx context.Context) (*RecreateResult, error) { - // Check if the service already exists - log.Infof("checking existence of service '%s'", c.Env.Service) - var oldService *ecstypes.Service - var transitService *ecstypes.Service - var newService *ecstypes.Service - if o, err := c.Ecs.DescribeServices(ctx, &ecs.DescribeServicesInput{ - Cluster: &c.Env.Cluster, - Services: []string{c.Env.Service}, - }); err != nil { - return nil, xerrors.Errorf("couldn't describe service: %w", err) - } else if len(o.Services) == 0 { - return nil, xerrors.Errorf("service '%s' does not exist. Use 'cage up' instead", c.Env.Service) - } else { - oldService = &o.Services[0] - if *oldService.Status == "INACTIVE" { - return nil, xerrors.Errorf("service '%s' is already INACTIVE. Use 'cage up' instead", c.Env.Service) - } - } - var err error - // Create a new task definition - td, err := c.CreateNextTaskDefinition(ctx) - if err != nil { - return nil, err - } - transitServiceName := fmt.Sprintf("%s-%d", *oldService.ServiceName, c.Time.Now().Unix()) - c.Env.ServiceDefinitionInput.TaskDefinition = td.TaskDefinitionArn - curDesiredCount := oldService.DesiredCount - newServiceInput := *c.Env.ServiceDefinitionInput - transitServiceDifinitonInput := *c.Env.ServiceDefinitionInput - transitServiceDifinitonInput.ServiceName = &transitServiceName - transitServiceDifinitonInput.DesiredCount = aws.Int32(1) - // Create a transit service - if transitService, err = c.createService(ctx, &transitServiceDifinitonInput); err != nil { - return nil, err - } - // Update transit service to same task count as previous service - if err = c.updateServiceTaskCount(ctx, *transitService.ServiceName, oldService.DesiredCount); err != nil { - return nil, err - } - // Update old service to 0 tasks - if err = c.updateServiceTaskCount(ctx, *oldService.ServiceName, 0); err != nil { - return nil, err - } - // Delete old service - if err = c.deleteService(ctx, *oldService.ServiceName); err != nil { - return nil, err - } - oldService = nil - // Create a new service - if newService, err = c.createService(ctx, &newServiceInput); err != nil { - return nil, err - } - // Update new service to same task count as transit service - if err = c.updateServiceTaskCount(ctx, *newService.ServiceName, curDesiredCount); err != nil { - return nil, err - } - // Update transit service to 0 tasks - if err = c.updateServiceTaskCount(ctx, *transitService.ServiceName, 0); err != nil { - return nil, err - } - // Delete transit service - if err = c.deleteService(ctx, *transitService.ServiceName); err != nil { - return nil, err - } - transitService = nil - return &RecreateResult{TaskDefinition: td, Service: newService}, nil -} - -func (c *cage) createService(ctx context.Context, serviceDefinitionInput *ecs.CreateServiceInput) (*ecstypes.Service, error) { - log.Infof("creating service '%s' with task-definition '%s'...", *serviceDefinitionInput.ServiceName, *serviceDefinitionInput.TaskDefinition) - o, err := c.Ecs.CreateService(ctx, serviceDefinitionInput) - if err != nil { - return nil, xerrors.Errorf("failed to create service '%s': %w", *serviceDefinitionInput.ServiceName, err) - } - log.Infof("waiting for service '%s' to be STABLE", *serviceDefinitionInput.ServiceName) - if err := ecs.NewServicesStableWaiter(c.Ecs).Wait(ctx, &ecs.DescribeServicesInput{ - Cluster: &c.Env.Cluster, - Services: []string{*serviceDefinitionInput.ServiceName}, - }, c.MaxWait); err != nil { - return nil, xerrors.Errorf("failed to wait for service '%s' to be STABLE: %w", *serviceDefinitionInput.ServiceName, err) - } - return o.Service, nil -} - -func (c *cage) updateServiceTaskCount(ctx context.Context, service string, count int32) error { - log.Infof("updating service '%s' desired count to %d...", service, count) - if _, err := c.Ecs.UpdateService(ctx, &ecs.UpdateServiceInput{ - Cluster: &c.Env.Cluster, - Service: &service, - DesiredCount: &count, - }); err != nil { - return xerrors.Errorf("failed to update service '%s': %w", service, err) - } - log.Infof("waiting for service '%s' to be STABLE", service) - if err := ecs.NewServicesStableWaiter(c.Ecs).Wait(ctx, &ecs.DescribeServicesInput{ - Cluster: &c.Env.Cluster, - Services: []string{service}, - }, c.MaxWait); err != nil { - return xerrors.Errorf("failed to wait for service '%s' to be STABLE: %v", service, err) - } - return nil -} - -func (c *cage) deleteService(ctx context.Context, service string) error { - log.Infof("deleting service '%s'...", service) - if _, err := c.Ecs.DeleteService(ctx, &ecs.DeleteServiceInput{ - Cluster: &c.Env.Cluster, - Service: &service, - }); err != nil { - return xerrors.Errorf("failed to delete service '%s': %w", service, err) - } - log.Infof("waiting for service '%s' to be INACTIVE", service) - if err := ecs.NewServicesInactiveWaiter(c.Ecs).Wait(ctx, &ecs.DescribeServicesInput{ - Cluster: &c.Env.Cluster, - Services: []string{service}, - }, c.MaxWait); err != nil { - return xerrors.Errorf("failed to wait for service '%s' to be INACTIVE: %w", service, err) - } - return nil -} diff --git a/recreate_test.go b/recreate_test.go deleted file mode 100644 index ba0216b..0000000 --- a/recreate_test.go +++ /dev/null @@ -1,267 +0,0 @@ -package cage_test - -import ( - "context" - "fmt" - "testing" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/ecs" - ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" - "github.com/golang/mock/gomock" - cage "github.com/loilo-inc/canarycage" - "github.com/loilo-inc/canarycage/mocks/mock_awsiface" - "github.com/loilo-inc/canarycage/test" - "github.com/stretchr/testify/assert" -) - -func TestRecreate(t *testing.T) { - setup := func(t *testing.T, passPhase int) ( - cage.Cage, - *test.MockContext, - *mock_awsiface.MockEcsClient, - *gomock.Call, - ) { - env := test.DefaultEnvars() - ctrl := gomock.NewController(t) - m := mock_awsiface.NewMockEcsClient(ctrl) - mocker := test.NewMockContext() - mocker.CreateService(context.TODO(), env.ServiceDefinitionInput) - phases := []func() *gomock.Call{ - func() *gomock.Call { - // describe old service - return m.EXPECT().DescribeServices(gomock.Any(), gomock.Any()).DoAndReturn(mocker.DescribeServices) - }, - func() *gomock.Call { - // create next task definition - return m.EXPECT().RegisterTaskDefinition(gomock.Any(), gomock.Any()).DoAndReturn(mocker.RegisterTaskDefinition) - }, - } - waiter := func() *gomock.Call { - return m.EXPECT().DescribeServices(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(mocker.DescribeServices) - } - swapPhase := []func() *gomock.Call{ - func() *gomock.Call { - // create transition service - return m.EXPECT().CreateService(gomock.Any(), gomock.Any()).DoAndReturn(mocker.CreateService) - }, - // expect transition service to be ACTIVE - waiter, - func() *gomock.Call { - // update transition service's desired count to old service's desired count - return m.EXPECT().UpdateService(gomock.Any(), gomock.Any()).DoAndReturn(mocker.UpdateService) - }, - // expect transition service to be ACTIVE - waiter, - func() *gomock.Call { - // update old service's desired count to 0 - return m.EXPECT().UpdateService(gomock.Any(), gomock.Any()).DoAndReturn(mocker.UpdateService) - }, - // expect old service to be ACTIVE - waiter, - func() *gomock.Call { - // delete old service - return m.EXPECT().DeleteService(gomock.Any(), gomock.Any()).DoAndReturn(mocker.DeleteService) - }, - // expect old service to be INACTIVE - waiter, - } - allPhases := append(phases, swapPhase...) - allPhases = append(allPhases, swapPhase...) - i := 0 - var prevCall *gomock.Call - for { - if i == passPhase || i == len(allPhases) { - break - } - call := allPhases[i]() - if prevCall != nil { - call.After(prevCall) - } - prevCall = call - i++ - } - return cage.NewCage(&cage.Input{ - Env: env, - ECS: m, - ALB: nil, - EC2: nil, - Time: test.NewFakeTime(), - MaxWait: 1, - }), mocker, m, prevCall - } - t.Run("basic", func(t *testing.T) { - cagecli, mocker, _, _ := setup(t, -1) - result, err := cagecli.Recreate(context.Background()) - assert.NoError(t, err) - assert.NotNil(t, result.Service) - assert.NotNil(t, result.TaskDefinition) - assert.Equal(t, mocker.ActiveServiceSize(), 1) - assert.Equal(t, mocker.RunningTaskSize(), 1) - assert.Equal(t, len(mocker.TaskDefinitions.List()), 1) - assert.Equal(t, *mocker.Services["service"].ServiceName, *result.Service.ServiceName) - td := mocker.TaskDefinitions.List()[0] - assert.Equal(t, *td.TaskDefinitionArn, *result.TaskDefinition.TaskDefinitionArn) - assert.Equal(t, *mocker.Services["service"].TaskDefinition, *result.TaskDefinition.TaskDefinitionArn) - }) - t.Run("should error if failed to describe old service", func(t *testing.T) { - cagecli, _, ecsMock, _ := setup(t, 0) - ecsMock.EXPECT().DescribeServices(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("error")) - result, err := cagecli.Recreate(context.Background()) - assert.EqualError(t, err, "couldn't describe service: error") - assert.Nil(t, result) - }) - t.Run("should error if old service doesn't exist", func(t *testing.T) { - cagecli, _, ecsMock, _ := setup(t, 0) - ecsMock.EXPECT().DescribeServices(gomock.Any(), gomock.Any()).Return( - &ecs.DescribeServicesOutput{Services: nil}, nil, - ) - result, err := cagecli.Recreate(context.Background()) - assert.EqualError(t, err, "service 'service' does not exist. Use 'cage up' instead") - assert.Nil(t, result) - }) - t.Run("should error if old service is already INACTIVE", func(t *testing.T) { - cagecli, _, ecsMock, _ := setup(t, 0) - ecsMock.EXPECT().DescribeServices(gomock.Any(), gomock.Any()).Return( - &ecs.DescribeServicesOutput{Services: []ecstypes.Service{{Status: aws.String("INACTIVE")}}}, nil, - ) - result, err := cagecli.Recreate(context.Background()) - assert.EqualError(t, err, "service 'service' is already INACTIVE. Use 'cage up' instead") - assert.Nil(t, result) - }) - t.Run("should error if failed to create next task definition", func(t *testing.T) { - cagecli, _, ecsMock, call := setup(t, 1) - ecsMock.EXPECT().RegisterTaskDefinition(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("error")).After(call) - result, err := cagecli.Recreate(context.Background()) - assert.EqualError(t, err, "failed to register next task definition: error") - assert.Nil(t, result) - }) - t.Run("should error if failed to create transition service", func(t *testing.T) { - cagecli, _, ecsMock, call := setup(t, 2) - ecsMock.EXPECT().CreateService(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("error")).After(call) - result, err := cagecli.Recreate(context.Background()) - assert.ErrorContains(t, err, "failed to create service") - assert.Nil(t, result) - }) - t.Run("should error if transition service is not ACTIVE", func(t *testing.T) { - cagecli, _, ecsMock, call := setup(t, 3) - ecsMock.EXPECT().DescribeServices(gomock.Any(), gomock.Any(), gomock.Any()).Return( - &ecs.DescribeServicesOutput{Failures: []ecstypes.Failure{{Reason: aws.String("MISSING")}}}, nil, - ).After(call) - result, err := cagecli.Recreate(context.Background()) - assert.ErrorContains(t, err, "failed to wait for service") - assert.Nil(t, result) - }) - t.Run("should error if failed to update transition service's desired count", func(t *testing.T) { - cagecli, _, ecsMock, call := setup(t, 4) - ecsMock.EXPECT().UpdateService(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("error")).After(call) - result, err := cagecli.Recreate(context.Background()) - assert.ErrorContains(t, err, "failed to update service") - assert.Nil(t, result) - }) - t.Run("should error if transition service is not ACTIVE", func(t *testing.T) { - cagecli, _, ecsMock, call := setup(t, 5) - ecsMock.EXPECT().DescribeServices(gomock.Any(), gomock.Any(), gomock.Any()).Return( - &ecs.DescribeServicesOutput{Failures: []ecstypes.Failure{{Reason: aws.String("MISSING")}}}, nil, - ).After(call) - result, err := cagecli.Recreate(context.Background()) - assert.ErrorContains(t, err, "failed to wait for service") - assert.Nil(t, result) - }) - t.Run("should error if failed to update old service's desired count", func(t *testing.T) { - cagecli, _, ecsMock, call := setup(t, 6) - ecsMock.EXPECT().UpdateService(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("error")).After(call) - result, err := cagecli.Recreate(context.Background()) - assert.ErrorContains(t, err, "failed to update service") - assert.Nil(t, result) - }) - t.Run("should error if old service is not ACTIVE", func(t *testing.T) { - cagecli, _, ecsMock, call := setup(t, 7) - ecsMock.EXPECT().DescribeServices(gomock.Any(), gomock.Any(), gomock.Any()).Return( - &ecs.DescribeServicesOutput{Failures: []ecstypes.Failure{{Reason: aws.String("MISSING")}}}, nil, - ).After(call) - result, err := cagecli.Recreate(context.Background()) - assert.ErrorContains(t, err, "failed to wait for service") - assert.Nil(t, result) - }) - t.Run("should error if failed to delete old service", func(t *testing.T) { - cagecli, _, ecsMock, call := setup(t, 8) - ecsMock.EXPECT().DeleteService(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("error")).After(call) - result, err := cagecli.Recreate(context.Background()) - assert.ErrorContains(t, err, "failed to delete service") - assert.Nil(t, result) - }) - t.Run("should error if old service is not INACTIVE", func(t *testing.T) { - cagecli, _, ecsMock, call := setup(t, 9) - ecsMock.EXPECT().DescribeServices(gomock.Any(), gomock.Any(), gomock.Any()).Return( - &ecs.DescribeServicesOutput{Failures: []ecstypes.Failure{{Reason: aws.String("MISSING")}}}, nil, - ).After(call) - result, err := cagecli.Recreate(context.Background()) - assert.ErrorContains(t, err, "failed to wait for service") - assert.Nil(t, result) - }) - t.Run("should error if failed to create new service", func(t *testing.T) { - cagecli, _, ecsMock, call := setup(t, 10) - ecsMock.EXPECT().CreateService(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("error")).After(call) - result, err := cagecli.Recreate(context.Background()) - assert.ErrorContains(t, err, "failed to create service") - assert.Nil(t, result) - }) - t.Run("should error if new service is not ACTIVE", func(t *testing.T) { - cagecli, _, ecsMock, call := setup(t, 11) - ecsMock.EXPECT().DescribeServices(gomock.Any(), gomock.Any(), gomock.Any()).Return( - &ecs.DescribeServicesOutput{Failures: []ecstypes.Failure{{Reason: aws.String("MISSING")}}}, nil, - ).After(call) - result, err := cagecli.Recreate(context.Background()) - assert.ErrorContains(t, err, "failed to wait for service") - assert.Nil(t, result) - }) - t.Run("should error if failed to update new service's desired count", func(t *testing.T) { - cagecli, _, ecsMock, call := setup(t, 12) - ecsMock.EXPECT().UpdateService(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("error")).After(call) - result, err := cagecli.Recreate(context.Background()) - assert.ErrorContains(t, err, "failed to update service") - assert.Nil(t, result) - }) - t.Run("should error if old service is not ACTIVE", func(t *testing.T) { - cagecli, _, ecsMock, call := setup(t, 13) - ecsMock.EXPECT().DescribeServices(gomock.Any(), gomock.Any(), gomock.Any()).Return( - &ecs.DescribeServicesOutput{Failures: []ecstypes.Failure{{Reason: aws.String("MISSING")}}}, nil, - ).After(call) - result, err := cagecli.Recreate(context.Background()) - assert.ErrorContains(t, err, "failed to wait for service") - assert.Nil(t, result) - }) - t.Run("should error if failed to update transition service's desired count", func(t *testing.T) { - cagecli, _, ecsMock, call := setup(t, 14) - ecsMock.EXPECT().UpdateService(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("error")).After(call) - result, err := cagecli.Recreate(context.Background()) - assert.ErrorContains(t, err, "failed to update service") - assert.Nil(t, result) - }) - t.Run("should error if transition service is not ACTIVE", func(t *testing.T) { - cagecli, _, ecsMock, call := setup(t, 15) - ecsMock.EXPECT().DescribeServices(gomock.Any(), gomock.Any(), gomock.Any()).Return( - &ecs.DescribeServicesOutput{Failures: []ecstypes.Failure{{Reason: aws.String("MISSING")}}}, nil, - ).After(call) - result, err := cagecli.Recreate(context.Background()) - assert.ErrorContains(t, err, "failed to wait for service") - assert.Nil(t, result) - }) - t.Run("should error if failed to delete old service", func(t *testing.T) { - cagecli, _, ecsMock, call := setup(t, 16) - ecsMock.EXPECT().DeleteService(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("error")).After(call) - result, err := cagecli.Recreate(context.Background()) - assert.ErrorContains(t, err, "failed to delete service") - assert.Nil(t, result) - }) - t.Run("should error if old service is not INACTIVE", func(t *testing.T) { - cagecli, _, ecsMock, call := setup(t, 17) - ecsMock.EXPECT().DescribeServices(gomock.Any(), gomock.Any(), gomock.Any()).Return( - &ecs.DescribeServicesOutput{Failures: []ecstypes.Failure{{Reason: aws.String("MISSING")}}}, nil, - ).After(call) - result, err := cagecli.Recreate(context.Background()) - assert.ErrorContains(t, err, "failed to wait for service") - assert.Nil(t, result) - }) -} diff --git a/rollout.go b/rollout.go index 8ff7eb4..c9ce6a1 100644 --- a/rollout.go +++ b/rollout.go @@ -16,13 +16,18 @@ import ( "golang.org/x/xerrors" ) +type RollOutInput struct { + // UpdateService is a flag to update service with changed configurations except for task definition + UpdateService bool +} + type RollOutResult struct { StartTime time.Time EndTime time.Time ServiceIntact bool } -func (c *cage) RollOut(ctx context.Context) (*RollOutResult, error) { +func (c *cage) RollOut(ctx context.Context, input *RollOutInput) (*RollOutResult, error) { ret := &RollOutResult{ StartTime: c.Time.Now(), ServiceIntact: true, @@ -70,7 +75,7 @@ func (c *cage) RollOut(ctx context.Context) (*RollOutResult, error) { } log.Infof("starting canary task...") var canaryTask *StartCanaryTaskOutput - if o, err := c.StartCanaryTask(ctx, nextTaskDefinition); err != nil { + if o, err := c.StartCanaryTask(ctx, nextTaskDefinition, input); err != nil { log.Errorf("failed to start canary task due to: %s", err) return throw(err) } else { @@ -121,16 +126,23 @@ func (c *cage) RollOut(ctx context.Context) (*RollOutResult, error) { "updating the task definition of '%s' into '%s:%d'...", c.Env.Service, *nextTaskDefinition.Family, nextTaskDefinition.Revision, ) - if _, err := c.Ecs.UpdateService(ctx, &ecs.UpdateServiceInput{ + updateInput := &ecs.UpdateServiceInput{ Cluster: &c.Env.Cluster, Service: &c.Env.Service, TaskDefinition: nextTaskDefinition.TaskDefinitionArn, - }); err != nil { + } + if input.UpdateService { + updateInput.LoadBalancers = c.Env.ServiceDefinitionInput.LoadBalancers + updateInput.NetworkConfiguration = c.Env.ServiceDefinitionInput.NetworkConfiguration + updateInput.ServiceConnectConfiguration = c.Env.ServiceDefinitionInput.ServiceConnectConfiguration + updateInput.ServiceRegistries = c.Env.ServiceDefinitionInput.ServiceRegistries + updateInput.PlatformVersion = c.Env.ServiceDefinitionInput.PlatformVersion + updateInput.VolumeConfigurations = c.Env.ServiceDefinitionInput.VolumeConfigurations + } + if _, err := c.Ecs.UpdateService(ctx, updateInput); err != nil { return throw(err) } log.Infof("waiting for service '%s' to be stable...", c.Env.Service) - //TODO: avoid stdout sticking while CI - if err := ecs.NewServicesStableWaiter(c.Ecs).Wait(ctx, &ecs.DescribeServicesInput{ Cluster: &c.Env.Cluster, Services: []string{c.Env.Service}, @@ -221,15 +233,30 @@ type StartCanaryTaskOutput struct { targetPort *int32 } -func (c *cage) StartCanaryTask(ctx context.Context, nextTaskDefinition *ecstypes.TaskDefinition) (*StartCanaryTaskOutput, error) { - var service ecstypes.Service - if o, err := c.Ecs.DescribeServices(ctx, &ecs.DescribeServicesInput{ - Cluster: &c.Env.Cluster, - Services: []string{c.Env.Service}, - }); err != nil { - return nil, err +func (c *cage) StartCanaryTask( + ctx context.Context, + nextTaskDefinition *ecstypes.TaskDefinition, + input *RollOutInput, +) (*StartCanaryTaskOutput, error) { + var networkConfiguration *ecstypes.NetworkConfiguration + var platformVersion *string + var loadBalancers []ecstypes.LoadBalancer + if input.UpdateService { + networkConfiguration = c.Env.ServiceDefinitionInput.NetworkConfiguration + platformVersion = c.Env.ServiceDefinitionInput.PlatformVersion + loadBalancers = c.Env.ServiceDefinitionInput.LoadBalancers } else { - service = o.Services[0] + if o, err := c.Ecs.DescribeServices(ctx, &ecs.DescribeServicesInput{ + Cluster: &c.Env.Cluster, + Services: []string{c.Env.Service}, + }); err != nil { + return nil, err + } else { + service := o.Services[0] + networkConfiguration = service.NetworkConfiguration + platformVersion = service.PlatformVersion + loadBalancers = service.LoadBalancers + } } var taskArn *string if c.Env.CanaryInstanceArn != "" { @@ -237,7 +264,7 @@ func (c *cage) StartCanaryTask(ctx context.Context, nextTaskDefinition *ecstypes startTask := &ecs.StartTaskInput{ Cluster: &c.Env.Cluster, Group: aws.String(fmt.Sprintf("cage:canary-task:%s", c.Env.Service)), - NetworkConfiguration: service.NetworkConfiguration, + NetworkConfiguration: networkConfiguration, TaskDefinition: nextTaskDefinition.TaskDefinitionArn, ContainerInstances: []string{c.Env.CanaryInstanceArn}, } @@ -251,10 +278,10 @@ func (c *cage) StartCanaryTask(ctx context.Context, nextTaskDefinition *ecstypes if o, err := c.Ecs.RunTask(ctx, &ecs.RunTaskInput{ Cluster: &c.Env.Cluster, Group: aws.String(fmt.Sprintf("cage:canary-task:%s", c.Env.Service)), - NetworkConfiguration: service.NetworkConfiguration, + NetworkConfiguration: networkConfiguration, TaskDefinition: nextTaskDefinition.TaskDefinitionArn, LaunchType: ecstypes.LaunchTypeFargate, - PlatformVersion: service.PlatformVersion, + PlatformVersion: platformVersion, }); err != nil { return nil, err } else { @@ -278,8 +305,8 @@ func (c *cage) StartCanaryTask(ctx context.Context, nextTaskDefinition *ecstypes } else { task = o.Tasks[0] } - if len(service.LoadBalancers) == 0 { - log.Infof("no load balancer is attached to service '%s'. skip registration to target group", *service.ServiceName) + if len(loadBalancers) == 0 { + log.Infof("no load balancer is attached to service '%s'. skip registration to target group", c.Env.Service) log.Infof("wait %d seconds for ensuring the task goes stable", c.Env.CanaryTaskIdleDuration) wait := make(chan bool) go func() { @@ -316,7 +343,7 @@ func (c *cage) StartCanaryTask(ctx context.Context, nextTaskDefinition *ecstypes var targetPort *int32 var subnet ec2types.Subnet for _, container := range nextTaskDefinition.ContainerDefinitions { - if *container.Name == *service.LoadBalancers[0].ContainerName { + if *container.Name == *loadBalancers[0].ContainerName { targetPort = container.PortMappings[0].HostPort } } @@ -363,7 +390,7 @@ func (c *cage) StartCanaryTask(ctx context.Context, nextTaskDefinition *ecstypes log.Infof("canary task was placed: instanceId = '%s', hostPort = '%d', az = '%s'", *targetId, *targetPort, *subnet.AvailabilityZone) } if _, err := c.Alb.RegisterTargets(ctx, &elbv2.RegisterTargetsInput{ - TargetGroupArn: service.LoadBalancers[0].TargetGroupArn, + TargetGroupArn: loadBalancers[0].TargetGroupArn, Targets: []elbv2types.TargetDescription{{ AvailabilityZone: subnet.AvailabilityZone, Id: targetId, @@ -373,7 +400,7 @@ func (c *cage) StartCanaryTask(ctx context.Context, nextTaskDefinition *ecstypes return nil, err } return &StartCanaryTaskOutput{ - targetGroupArn: service.LoadBalancers[0].TargetGroupArn, + targetGroupArn: loadBalancers[0].TargetGroupArn, targetId: targetId, targetPort: targetPort, task: task, diff --git a/rollout_test.go b/rollout_test.go index 54a4dce..1191b2b 100644 --- a/rollout_test.go +++ b/rollout_test.go @@ -44,7 +44,7 @@ func TestCage_RollOut_FARGATE(t *testing.T) { Time: test.NewFakeTime(), }) ctx := context.Background() - result, err := cagecli.RollOut(ctx) + result, err := cagecli.RollOut(ctx, &cage.RollOutInput{}) assert.NoError(t, err) assert.False(t, result.ServiceIntact) assert.Equal(t, 1, mctx.ActiveServiceSize()) @@ -83,7 +83,7 @@ func TestCage_RollOut_FARGATE(t *testing.T) { Time: test.NewFakeTime(), }) ctx := context.Background() - result, err := cagecli.RollOut(ctx) + result, err := cagecli.RollOut(ctx, &cage.RollOutInput{}) assert.NoError(t, err) assert.NotNil(t, result) }) @@ -127,7 +127,7 @@ func TestCage_RollOut_FARGATE(t *testing.T) { Time: test.NewFakeTime(), }) ctx := context.Background() - _, err := cagecli.RollOut(ctx) + _, err := cagecli.RollOut(ctx, &cage.RollOutInput{}) assert.NotNil(t, err) }) @@ -143,7 +143,7 @@ func TestCage_RollOut_FARGATE(t *testing.T) { ALB: albMock, }) ctx := context.Background() - _, err := cagecli.RollOut(ctx) + _, err := cagecli.RollOut(ctx, &cage.RollOutInput{}) assert.EqualError(t, err, "service 'service' doesn't exist. Run 'cage up' or create service before rolling out") }) t.Run("Roll out even if the service does not have a load balancer", func(t *testing.T) { @@ -160,7 +160,7 @@ func TestCage_RollOut_FARGATE(t *testing.T) { Time: test.NewFakeTime(), }) ctx := context.Background() - if res, err := cagecli.RollOut(ctx); err != nil { + if res, err := cagecli.RollOut(ctx, &cage.RollOutInput{}); err != nil { t.Fatalf(err.Error()) } else if res.ServiceIntact { t.Fatalf("no") @@ -183,7 +183,7 @@ func TestCage_RollOut_FARGATE(t *testing.T) { ECS: ecsMock, Time: test.NewFakeTime(), }) - _, err := cagecli.RollOut(context.Background()) + _, err := cagecli.RollOut(context.Background(), &cage.RollOutInput{}) assert.EqualError(t, err, "😵 'service' status is 'INACTIVE'. Stop rolling out") }) t.Run("Stop rolling out if the canary task container does not become healthy", func(t *testing.T) { @@ -229,7 +229,7 @@ func TestCage_RollOut_FARGATE(t *testing.T) { Time: test.NewFakeTime(), }) ctx := context.Background() - res, err := cagecli.RollOut(ctx) + res, err := cagecli.RollOut(ctx, &cage.RollOutInput{}) assert.NotNil(t, res) assert.NotNil(t, err) @@ -274,7 +274,7 @@ func TestCage_RollOut_EC2(t *testing.T) { Time: test.NewFakeTime(), }) ctx := context.Background() - result, err := cagecli.RollOut(ctx) + result, err := cagecli.RollOut(ctx, &cage.RollOutInput{}) if err != nil { t.Fatalf("%s", err) } @@ -304,7 +304,7 @@ func TestCage_RollOut_EC2_without_ContainerInstanceArn(t *testing.T) { Time: test.NewFakeTime(), }) ctx := context.Background() - result, err := cagecli.RollOut(ctx) + result, err := cagecli.RollOut(ctx, &cage.RollOutInput{}) if err == nil { t.Fatal("Rollout with no container instance should be error") } else { @@ -339,7 +339,7 @@ func TestCage_RollOut_EC2_no_attribute(t *testing.T) { Time: test.NewFakeTime(), }) ctx := context.Background() - result, err := cagecli.RollOut(ctx) + result, err := cagecli.RollOut(ctx, &cage.RollOutInput{}) if err != nil { t.Fatalf("%s", err) } diff --git a/test/context.go b/test/context.go index f9cc437..7473c1d 100644 --- a/test/context.go +++ b/test/context.go @@ -167,6 +167,9 @@ func (ctx *MockContext) UpdateService(c context.Context, input *ecs.UpdateServic s.DesiredCount = nextDesiredCount s.TaskDefinition = nextTaskDefinition s.RunningCount = nextDesiredCount + s.ServiceRegistries = input.ServiceRegistries + s.NetworkConfiguration = input.NetworkConfiguration + s.LoadBalancers = input.LoadBalancers s.Deployments = []types.Deployment{ { DesiredCount: nextDesiredCount, diff --git a/up.go b/up.go index 740f951..b092e0f 100644 --- a/up.go +++ b/up.go @@ -38,3 +38,19 @@ func (c *cage) Up(ctx context.Context) (*UpResult, error) { return &UpResult{TaskDefinition: td, Service: service}, nil } } + +func (c *cage) createService(ctx context.Context, serviceDefinitionInput *ecs.CreateServiceInput) (*ecstypes.Service, error) { + log.Infof("creating service '%s' with task-definition '%s'...", *serviceDefinitionInput.ServiceName, *serviceDefinitionInput.TaskDefinition) + o, err := c.Ecs.CreateService(ctx, serviceDefinitionInput) + if err != nil { + return nil, xerrors.Errorf("failed to create service '%s': %w", *serviceDefinitionInput.ServiceName, err) + } + log.Infof("waiting for service '%s' to be STABLE", *serviceDefinitionInput.ServiceName) + if err := ecs.NewServicesStableWaiter(c.Ecs).Wait(ctx, &ecs.DescribeServicesInput{ + Cluster: &c.Env.Cluster, + Services: []string{*serviceDefinitionInput.ServiceName}, + }, c.MaxWait); err != nil { + return nil, xerrors.Errorf("failed to wait for service '%s' to be STABLE: %w", *serviceDefinitionInput.ServiceName, err) + } + return o.Service, nil +}