diff --git a/Makefile b/Makefile index 40492c798ee..f21a9438ebd 100644 --- a/Makefile +++ b/Makefile @@ -188,6 +188,6 @@ gen-mocks: tools ${GOBIN}/mockgen -package=mocks -destination=./internal/pkg/list/mocks/mock_list.go -source=./internal/pkg/list/list.go ${GOBIN}/mockgen -package=mocks -destination=./internal/pkg/initialize/mocks/mock_workload.go -source=./internal/pkg/initialize/workload.go ${GOBIN}/mockgen -package=mocks -destination=./internal/pkg/ecs/mocks/mock_ecs.go -source=./internal/pkg/ecs/ecs.go - ${GOBIN}/mockgen -package=mocks -destination=./internal/pkg/generator/mocks/mock_ecs_service.go -source=./internal/pkg/generator/ecs_service.go - ${GOBIN}/mockgen -package=mocks -destination=./internal/pkg/generator/mocks/mock_service.go -source=./internal/pkg/generator/service.go + ${GOBIN}/mockgen -package=mocks -destination=./internal/pkg/ecs/mocks/mock_run_task_request.go -source=./internal/pkg/ecs/run_task_request.go + diff --git a/internal/pkg/cli/flag.go b/internal/pkg/cli/flag.go index 4fc673eada3..74a9586d6b6 100644 --- a/internal/pkg/cli/flag.go +++ b/internal/pkg/cli/flag.go @@ -60,21 +60,22 @@ const ( storageRDSInitialDBFlag = "initial-db" storageRDSParameterGroupFlag = "parameter-group" - taskGroupNameFlag = "task-group-name" - countFlag = "count" - cpuFlag = "cpu" - memoryFlag = "memory" - imageFlag = "image" - taskRoleFlag = "task-role" - executionRoleFlag = "execution-role" - clusterFlag = "cluster" - subnetsFlag = "subnets" - securityGroupsFlag = "security-groups" - envVarsFlag = "env-vars" - secretsFlag = "secrets" - commandFlag = "command" - entrypointFlag = "entrypoint" - taskDefaultFlag = "default" + taskGroupNameFlag = "task-group-name" + countFlag = "count" + cpuFlag = "cpu" + memoryFlag = "memory" + imageFlag = "image" + taskRoleFlag = "task-role" + executionRoleFlag = "execution-role" + clusterFlag = "cluster" + subnetsFlag = "subnets" + securityGroupsFlag = "security-groups" + envVarsFlag = "env-vars" + secretsFlag = "secrets" + commandFlag = "command" + entrypointFlag = "entrypoint" + taskDefaultFlag = "default" + generateCommandFlag = "generate-cmd" vpcIDFlag = "import-vpc-id" publicSubnetsFlag = "import-public-subnets" @@ -235,7 +236,11 @@ Must be either "MySQL" or "PostgreSQL".` taskGroupFlagDescription = `Optional. The group name of the task. Tasks with the same group name share the same set of resources. (default directory name)` - taskImageTagFlagDescription = `Optional. The container image tag in addition to "latest".` + taskImageTagFlagDescription = `Optional. The container image tag in addition to "latest".` + generateCommandFlagDescription = `Optional. Generate a command with a pre-filled value for each flag. +To use it for an ECS service, specify --generate-cmd /. +Alternatively, if the service is created with Copilot, specify --generate-cmd //. +Cannot be specified with any other flags.` vpcIDFlagDescription = "Optional. Use an existing VPC ID." publicSubnetsFlagDescription = "Optional. Use existing public subnet IDs." diff --git a/internal/pkg/cli/interfaces.go b/internal/pkg/cli/interfaces.go index 409c2f329ea..060f0752ae3 100644 --- a/internal/pkg/cli/interfaces.go +++ b/internal/pkg/cli/interfaces.go @@ -569,3 +569,7 @@ type codestar interface { type publicIPGetter interface { PublicIP(ENI string) (string, error) } + +type cliStringer interface { + CLIString() string +} diff --git a/internal/pkg/cli/task_run.go b/internal/pkg/cli/task_run.go index 603d681413a..2c859a95b62 100644 --- a/internal/pkg/cli/task_run.go +++ b/internal/pkg/cli/task_run.go @@ -10,6 +10,8 @@ import ( "path/filepath" "strings" + "github.com/aws/aws-sdk-go/aws/arn" + awscloudformation "github.com/aws/copilot-cli/internal/pkg/aws/cloudformation" "github.com/aws/copilot-cli/internal/pkg/describe" "github.com/aws/copilot-cli/internal/pkg/logging" @@ -78,8 +80,8 @@ type runTaskVars struct { taskRole string executionRole string + cluster string - cluster string subnets []string securityGroups []string env string @@ -92,12 +94,14 @@ type runTaskVars struct { entrypoint string resourceTags map[string]string - follow bool + follow bool + generateCommandTarget string } type runTaskOpts struct { runTaskVars isDockerfileSet bool + nFlag int // Interfaces to interact with dependencies. fs afero.Fs @@ -116,11 +120,15 @@ type runTaskOpts struct { sess *session.Session targetEnvironment *config.Environment - // Configurer methods. + // Configurer functions. configureRuntimeOpts func() error configureRepository func() error // NOTE: configureEventsWriter is only called when tailing logs (i.e. --follow is specified) configureEventsWriter func(tasks []*task.Task) + + // Functions to generate a task run command. + runTaskRequestFromECSService func(client ecs.ECSServiceDescriber, cluster, service string) (*ecs.RunTaskRequest, error) + runTaskRequestFromService func(client ecs.ServiceDescriber, app, env, svc string) (*ecs.RunTaskRequest, error) } func newTaskRunOpts(vars runTaskVars) (*runTaskOpts, error) { @@ -163,6 +171,9 @@ func newTaskRunOpts(vars runTaskVars) (*runTaskOpts, error) { opts.configureEventsWriter = func(tasks []*task.Task) { opts.eventsWriter = logging.NewTaskClient(opts.sess, opts.groupName, tasks) } + + opts.runTaskRequestFromECSService = ecs.RunTaskRequestFromECSService + opts.runTaskRequestFromService = ecs.RunTaskRequestFromService return &opts, nil } @@ -247,6 +258,12 @@ func (o *runTaskOpts) configureSessAndEnv() error { // Validate returns an error if the flag values passed by the user are invalid. func (o *runTaskOpts) Validate() error { + if o.generateCommandTarget != "" { + if o.nFlag >= 2 { + return errors.New("cannot specify `--generate-cmd` with any other flag") + } + } + if o.count <= 0 { return errNumNotPositive } @@ -383,6 +400,9 @@ func (o *runTaskOpts) validateFlagsWithSecurityGroups() error { // Ask prompts the user for any required or important fields that are not provided. func (o *runTaskOpts) Ask() error { + if o.generateCommandTarget != "" { + return nil + } if o.shouldPromptForAppEnv() { if err := o.askAppName(); err != nil { return err @@ -398,7 +418,7 @@ func (o *runTaskOpts) shouldPromptForAppEnv() bool { // NOTE: if security groups are specified but subnets are not, then we use the default subnets with the // specified security groups. useDefault := o.useDefaultSubnetsAndCluster || (o.securityGroups != nil && o.subnets == nil && o.cluster == "") - useConfig := o.subnets != nil + useConfig := o.subnets != nil || o.cluster != "" // if user hasn't specified that they want to use the default subnets, and that they didn't provide specific subnets // that they want to use, then we prompt. @@ -407,6 +427,10 @@ func (o *runTaskOpts) shouldPromptForAppEnv() bool { // Execute deploys and runs the task. func (o *runTaskOpts) Execute() error { + if o.generateCommandTarget != "" { + return o.generateCommand() + } + if o.groupName == "" { dir, err := os.Getwd() if err != nil { @@ -481,6 +505,76 @@ func (o *runTaskOpts) Execute() error { return nil } +func (o *runTaskOpts) generateCommand() error { + command, err := o.runTaskCommand() + if err != nil { + return err + } + log.Infoln(command.CLIString()) + return nil +} + +func (o *runTaskOpts) runTaskCommand() (cliStringer, error) { + var cmd cliStringer + sess, err := sessions.NewProvider().Default() + if err != nil { + return nil, fmt.Errorf("get default session: %s", err) + } + + if arn.IsARN(o.generateCommandTarget) { + clusterName, serviceName, err := o.parseARN() + if err != nil { + return nil, err + } + return o.runTaskCommandFromECSService(sess, clusterName, serviceName) + } + + parts := strings.Split(o.generateCommandTarget, "/") + switch len(parts) { + case 2: + clusterName, serviceName := parts[0], parts[1] + cmd, err = o.runTaskCommandFromECSService(sess, clusterName, serviceName) + if err != nil { + return nil, err + } + case 3: + appName, envName, serviceName := parts[0], parts[1], parts[2] + cmd, err = o.runTaskRequestFromService(ecs.New(sess), appName, envName, serviceName) + if err != nil { + return nil, fmt.Errorf("generate task run command from service %s of application %s deployed in environment %s: %w", serviceName, appName, envName, err) + } + default: + return nil, errors.New("invalid input to --generate-cmd: must be of one the form / or //") + } + + return cmd, nil +} + +func (o *runTaskOpts) parseARN() (string, string, error) { + svcARN := awsecs.ServiceArn(o.generateCommandTarget) + clusterName, err := svcARN.ClusterName() + if err != nil { + return "", "", fmt.Errorf("extract cluster name from arn %s: %w", svcARN, err) + } + serviceName, err := svcARN.ServiceName() + if err != nil { + return "", "", fmt.Errorf("extract service name from arn %s: %w", svcARN, err) + } + return clusterName, serviceName, nil +} + +func (o *runTaskOpts) runTaskCommandFromECSService(sess *session.Session, clusterName, serviceName string) (cliStringer, error) { + cmd, err := o.runTaskRequestFromECSService(awsecs.New(sess), clusterName, serviceName) + if err != nil { + var errMultipleContainers *ecs.ErrMultipleContainersInTaskDef + if errors.As(err, &errMultipleContainers) { + log.Errorln("`copilot task run` does not support running more than one container.") + } + return nil, fmt.Errorf("generate task run command from ECS service %s: %w", clusterName+"/"+serviceName, err) + } + return cmd, nil +} + func (o *runTaskOpts) displayLogStream() error { if err := o.eventsWriter.WriteEventsUntilStopped(); err != nil { return fmt.Errorf("write events: %w", err) @@ -687,6 +781,7 @@ Run a task with a command. /code $ copilot task run --command "python migrate-script.py"`, RunE: runCmdE(func(cmd *cobra.Command, args []string) error { opts, err := newTaskRunOpts(vars) + opts.nFlag = cmd.Flags().NFlag() if err != nil { return err } @@ -737,5 +832,7 @@ Run a task with a command. cmd.Flags().StringToStringVar(&vars.resourceTags, resourceTagsFlag, nil, resourceTagsFlagDescription) cmd.Flags().BoolVar(&vars.follow, followFlag, false, followFlagDescription) + cmd.Flags().StringVar(&vars.generateCommandTarget, generateCommandFlag, "", generateCommandFlagDescription) + return cmd } diff --git a/internal/pkg/cli/task_run_test.go b/internal/pkg/cli/task_run_test.go index af96e7c9f3f..b848ea83a10 100644 --- a/internal/pkg/cli/task_run_test.go +++ b/internal/pkg/cli/task_run_test.go @@ -9,6 +9,8 @@ import ( "path/filepath" "testing" + "github.com/aws/copilot-cli/internal/pkg/ecs" + "github.com/aws/copilot-cli/internal/pkg/cli/mocks" "github.com/aws/copilot-cli/internal/pkg/exec" @@ -60,7 +62,8 @@ func TestTaskRunOpts_Validate(t *testing.T) { inCommand string inEntryPoint string - inDefault bool + inDefault bool + inGenerateCommandTarget string appName string isDockerfileSet bool @@ -302,6 +305,13 @@ func TestTaskRunOpts_Validate(t *testing.T) { wantedError: errors.New("cannot specify both `--env` and `--cluster`"), }, + "generate-cmd specified with another flag": { + basicOpts: defaultOpts, + + inGenerateCommandTarget: "cluster/service", // nFlag is set to 2. + + wantedError: errors.New("cannot specify `--generate-cmd` with any other flag"), + }, } for name, tc := range testCases { @@ -330,8 +340,10 @@ func TestTaskRunOpts_Validate(t *testing.T) { command: tc.inCommand, entrypoint: tc.inEntryPoint, useDefaultSubnetsAndCluster: tc.inDefault, + generateCommandTarget: tc.inGenerateCommandTarget, }, isDockerfileSet: tc.isDockerfileSet, + nFlag: 2, fs: &afero.Afero{Fs: afero.NewMemMapFs()}, store: mockStore, @@ -892,3 +904,99 @@ func TestTaskRunOpts_Execute(t *testing.T) { }) } } + +type mockRunTaskRequester struct { + mockRunTaskRequestFromECSService func(client ecs.ECSServiceDescriber, cluster string, service string) (*ecs.RunTaskRequest, error) + mockRunTaskRequestFromService func(client ecs.ServiceDescriber, app, env, svc string) (*ecs.RunTaskRequest, error) +} + +func TestTaskRunOpts_runTaskCommand(t *testing.T) { + wantedCommand := ecs.RunTaskRequest{} + + testCases := map[string]struct { + inGenerateCommandTarget string + + m mockRunTaskRequester + + wantedCommand *ecs.RunTaskRequest + wantedError error + }{ + "should generate a command given an service ARN": { + inGenerateCommandTarget: "arn:aws:ecs:us-east-1:123456789012:service/crowded-cluster/good-service", + m: mockRunTaskRequester{ + mockRunTaskRequestFromECSService: func(client ecs.ECSServiceDescriber, cluster string, service string) (*ecs.RunTaskRequest, error) { + return &wantedCommand, nil + }, + }, + wantedCommand: &wantedCommand, + }, + "fail to generate a command given an service ARN": { + inGenerateCommandTarget: "arn:aws:ecs:us-east-1:123456789012:service/crowded-cluster/good-service", + m: mockRunTaskRequester{ + mockRunTaskRequestFromECSService: func(client ecs.ECSServiceDescriber, cluster string, service string) (*ecs.RunTaskRequest, error) { + return nil, errors.New("some error") + }, + }, + wantedError: fmt.Errorf("generate task run command from ECS service crowded-cluster/good-service: some error"), + }, + "should generate a command given a cluster/service target": { + inGenerateCommandTarget: "crowded-cluster/good-service", + m: mockRunTaskRequester{ + mockRunTaskRequestFromECSService: func(client ecs.ECSServiceDescriber, cluster string, service string) (*ecs.RunTaskRequest, error) { + return &wantedCommand, nil + }, + }, + wantedCommand: &wantedCommand, + }, + "fail to generate a command given a cluster/service target": { + inGenerateCommandTarget: "crowded-cluster/good-service", + m: mockRunTaskRequester{ + mockRunTaskRequestFromECSService: func(client ecs.ECSServiceDescriber, cluster string, service string) (*ecs.RunTaskRequest, error) { + return nil, errors.New("some error") + }, + }, + wantedError: fmt.Errorf("generate task run command from ECS service crowded-cluster/good-service: some error"), + }, + "should generate a command given an app/env/svc target": { + inGenerateCommandTarget: "good-app/good-env/good-service", + m: mockRunTaskRequester{ + mockRunTaskRequestFromService: func(client ecs.ServiceDescriber, app, env, svc string) (*ecs.RunTaskRequest, error) { + return &wantedCommand, nil + }, + }, + wantedCommand: &wantedCommand, + }, + "fail to generate a command given an app/env/svc target": { + inGenerateCommandTarget: "good-app/good-env/good-service", + m: mockRunTaskRequester{ + mockRunTaskRequestFromService: func(client ecs.ServiceDescriber, app, env, svc string) (*ecs.RunTaskRequest, error) { + return nil, errors.New("some error") + }, + }, + wantedError: fmt.Errorf("generate task run command from service good-service of application good-app deployed in environment good-env: some error"), + }, + "invalid input": { + inGenerateCommandTarget: "invalid/illegal/not-good/input/is/bad", + wantedError: errors.New("invalid input to --generate-cmd: must be of one the form / or //"), + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + opts := &runTaskOpts{ + runTaskVars: runTaskVars{ + generateCommandTarget: tc.inGenerateCommandTarget, + }, + runTaskRequestFromECSService: tc.m.mockRunTaskRequestFromECSService, + runTaskRequestFromService: tc.m.mockRunTaskRequestFromService, + } + + got, err := opts.runTaskCommand() + if tc.wantedError != nil { + require.EqualError(t, tc.wantedError, err.Error()) + } else { + require.NoError(t, err) + require.Equal(t, tc.wantedCommand, got) + } + }) + } +} diff --git a/internal/pkg/ecs/errors.go b/internal/pkg/ecs/errors.go new file mode 100644 index 00000000000..b7a0d2baca9 --- /dev/null +++ b/internal/pkg/ecs/errors.go @@ -0,0 +1,14 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package ecs + +import "fmt" + +type ErrMultipleContainersInTaskDef struct { + taskDefIdentifier string +} + +func (e *ErrMultipleContainersInTaskDef) Error() string { + return fmt.Sprintf("found more than one container in task definition: %s", e.taskDefIdentifier) +} diff --git a/internal/pkg/ecs/mocks/mock_run_task_request.go b/internal/pkg/ecs/mocks/mock_run_task_request.go new file mode 100644 index 00000000000..2c3efdbec1b --- /dev/null +++ b/internal/pkg/ecs/mocks/mock_run_task_request.go @@ -0,0 +1,148 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./internal/pkg/ecs/run_task_request.go + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + ecs "github.com/aws/copilot-cli/internal/pkg/aws/ecs" + gomock "github.com/golang/mock/gomock" +) + +// MockecsServiceDescriber is a mock of ecsServiceDescriber interface. +type MockecsServiceDescriber struct { + ctrl *gomock.Controller + recorder *MockecsServiceDescriberMockRecorder +} + +// MockecsServiceDescriberMockRecorder is the mock recorder for MockecsServiceDescriber. +type MockecsServiceDescriberMockRecorder struct { + mock *MockecsServiceDescriber +} + +// NewMockecsServiceDescriber creates a new mock instance. +func NewMockecsServiceDescriber(ctrl *gomock.Controller) *MockecsServiceDescriber { + mock := &MockecsServiceDescriber{ctrl: ctrl} + mock.recorder = &MockecsServiceDescriberMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockecsServiceDescriber) EXPECT() *MockecsServiceDescriberMockRecorder { + return m.recorder +} + +// NetworkConfiguration mocks base method. +func (m *MockecsServiceDescriber) NetworkConfiguration(cluster, serviceName string) (*ecs.NetworkConfiguration, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NetworkConfiguration", cluster, serviceName) + ret0, _ := ret[0].(*ecs.NetworkConfiguration) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NetworkConfiguration indicates an expected call of NetworkConfiguration. +func (mr *MockecsServiceDescriberMockRecorder) NetworkConfiguration(cluster, serviceName interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NetworkConfiguration", reflect.TypeOf((*MockecsServiceDescriber)(nil).NetworkConfiguration), cluster, serviceName) +} + +// Service mocks base method. +func (m *MockecsServiceDescriber) Service(clusterName, serviceName string) (*ecs.Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Service", clusterName, serviceName) + ret0, _ := ret[0].(*ecs.Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Service indicates an expected call of Service. +func (mr *MockecsServiceDescriberMockRecorder) Service(clusterName, serviceName interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Service", reflect.TypeOf((*MockecsServiceDescriber)(nil).Service), clusterName, serviceName) +} + +// TaskDefinition mocks base method. +func (m *MockecsServiceDescriber) TaskDefinition(taskDefName string) (*ecs.TaskDefinition, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TaskDefinition", taskDefName) + ret0, _ := ret[0].(*ecs.TaskDefinition) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// TaskDefinition indicates an expected call of TaskDefinition. +func (mr *MockecsServiceDescriberMockRecorder) TaskDefinition(taskDefName interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TaskDefinition", reflect.TypeOf((*MockecsServiceDescriber)(nil).TaskDefinition), taskDefName) +} + +// MockserviceDescriber is a mock of serviceDescriber interface. +type MockserviceDescriber struct { + ctrl *gomock.Controller + recorder *MockserviceDescriberMockRecorder +} + +// MockserviceDescriberMockRecorder is the mock recorder for MockserviceDescriber. +type MockserviceDescriberMockRecorder struct { + mock *MockserviceDescriber +} + +// NewMockserviceDescriber creates a new mock instance. +func NewMockserviceDescriber(ctrl *gomock.Controller) *MockserviceDescriber { + mock := &MockserviceDescriber{ctrl: ctrl} + mock.recorder = &MockserviceDescriberMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockserviceDescriber) EXPECT() *MockserviceDescriberMockRecorder { + return m.recorder +} + +// ClusterARN mocks base method. +func (m *MockserviceDescriber) ClusterARN(app, env string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClusterARN", app, env) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ClusterARN indicates an expected call of ClusterARN. +func (mr *MockserviceDescriberMockRecorder) ClusterARN(app, env interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterARN", reflect.TypeOf((*MockserviceDescriber)(nil).ClusterARN), app, env) +} + +// NetworkConfiguration mocks base method. +func (m *MockserviceDescriber) NetworkConfiguration(app, env, svc string) (*ecs.NetworkConfiguration, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NetworkConfiguration", app, env, svc) + ret0, _ := ret[0].(*ecs.NetworkConfiguration) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NetworkConfiguration indicates an expected call of NetworkConfiguration. +func (mr *MockserviceDescriberMockRecorder) NetworkConfiguration(app, env, svc interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NetworkConfiguration", reflect.TypeOf((*MockserviceDescriber)(nil).NetworkConfiguration), app, env, svc) +} + +// TaskDefinition mocks base method. +func (m *MockserviceDescriber) TaskDefinition(app, env, svc string) (*ecs.TaskDefinition, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TaskDefinition", app, env, svc) + ret0, _ := ret[0].(*ecs.TaskDefinition) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// TaskDefinition indicates an expected call of TaskDefinition. +func (mr *MockserviceDescriberMockRecorder) TaskDefinition(app, env, svc interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TaskDefinition", reflect.TypeOf((*MockserviceDescriber)(nil).TaskDefinition), app, env, svc) +} diff --git a/internal/pkg/ecs/run_task_request.go b/internal/pkg/ecs/run_task_request.go new file mode 100644 index 00000000000..530315f3d31 --- /dev/null +++ b/internal/pkg/ecs/run_task_request.go @@ -0,0 +1,220 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package ecs provides a client to retrieve Copilot ECS information. +package ecs + +import ( + "fmt" + "sort" + "strings" + + "github.com/aws/aws-sdk-go/aws" + awsecs "github.com/aws/copilot-cli/internal/pkg/aws/ecs" +) + +// ECSServiceDescriber provides information on an ECS service. +type ECSServiceDescriber interface { + Service(clusterName, serviceName string) (*awsecs.Service, error) + TaskDefinition(taskDefName string) (*awsecs.TaskDefinition, error) + NetworkConfiguration(cluster, serviceName string) (*awsecs.NetworkConfiguration, error) +} + +// ServiceDescriber provides information on a Copilot service. +type ServiceDescriber interface { + TaskDefinition(app, env, svc string) (*awsecs.TaskDefinition, error) + NetworkConfiguration(app, env, svc string) (*awsecs.NetworkConfiguration, error) + ClusterARN(app, env string) (string, error) +} + +// RunTaskRequest contains information to generate a task run command. +type RunTaskRequest struct { + networkConfiguration awsecs.NetworkConfiguration + + executionRole string + taskRole string + cluster string + + containerInfo +} + +type containerInfo struct { + image string + entryPoint []string + command []string + envVars map[string]string + secrets map[string]string +} + +// RunTaskRequestFromECSService populates a RunTaskRequest with information from an ECS service. +func RunTaskRequestFromECSService(client ECSServiceDescriber, cluster, service string) (*RunTaskRequest, error) { + networkConfig, err := client.NetworkConfiguration(cluster, service) + if err != nil { + return nil, fmt.Errorf("retrieve network configuration for service %s in cluster %s: %w", service, cluster, err) + } + + svc, err := client.Service(cluster, service) + if err != nil { + return nil, fmt.Errorf("retrieve service %s in cluster %s: %w", service, cluster, err) + } + + taskDefNameOrARN := aws.StringValue(svc.TaskDefinition) + taskDef, err := client.TaskDefinition(taskDefNameOrARN) + if err != nil { + return nil, fmt.Errorf("retrieve task definition %s: %w", taskDefNameOrARN, err) + } + + if len(taskDef.ContainerDefinitions) > 1 { + return nil, &ErrMultipleContainersInTaskDef{ + taskDefIdentifier: taskDefNameOrARN, + } + } + + containerName := aws.StringValue(taskDef.ContainerDefinitions[0].Name) + containerInfo, err := containerInformation(taskDef, containerName) + if err != nil { + return nil, err + } + + return &RunTaskRequest{ + networkConfiguration: *networkConfig, + executionRole: aws.StringValue(taskDef.ExecutionRoleArn), + taskRole: aws.StringValue(taskDef.TaskRoleArn), + containerInfo: *containerInfo, + cluster: cluster, + }, nil +} + +// RunTaskRequestFromECSService populates a RunTaskRequest with information from a Copilot service. +func RunTaskRequestFromService(client ServiceDescriber, app, env, svc string) (*RunTaskRequest, error) { + networkConfig, err := client.NetworkConfiguration(app, env, svc) + if err != nil { + return nil, fmt.Errorf("retrieve network configuration for service %s: %w", svc, err) + } + + cluster, err := client.ClusterARN(app, env) + if err != nil { + return nil, fmt.Errorf("retrieve cluster ARN created for environment %s in application %s: %w", env, app, err) + } + + taskDef, err := client.TaskDefinition(app, env, svc) + if err != nil { + return nil, fmt.Errorf("retrieve task definition for service %s: %w", svc, err) + } + + containerName := svc // NOTE: refer to workload's CloudFormation template. The container name is set to be the workload's name. + containerInfo, err := containerInformation(taskDef, containerName) + if err != nil { + return nil, err + } + + return &RunTaskRequest{ + networkConfiguration: *networkConfig, + executionRole: aws.StringValue(taskDef.ExecutionRoleArn), + taskRole: aws.StringValue(taskDef.TaskRoleArn), + containerInfo: *containerInfo, + cluster: cluster, + }, nil +} + +// String stringifies a RunTaskRequest. +func (r RunTaskRequest) CLIString() string { + output := []string{"copilot task run"} + if r.executionRole != "" { + output = append(output, fmt.Sprintf("--execution-role %s", r.executionRole)) + } + + if r.taskRole != "" { + output = append(output, fmt.Sprintf("--task-role %s", r.taskRole)) + } + + if r.image != "" { + output = append(output, fmt.Sprintf("--image %s", r.image)) + } + + if r.entryPoint != nil { + output = append(output, fmt.Sprintf("--entrypoint %s", fmt.Sprintf("\"%s\"", strings.Join(r.entryPoint, " ")))) + } + + if r.command != nil { + output = append(output, fmt.Sprintf("--command %s", fmt.Sprintf("\"%s\"", strings.Join(r.command, " ")))) + } + + if r.envVars != nil && len(r.envVars) != 0 { + output = append(output, fmt.Sprintf("--env-vars %s", fmtStringMapToString(r.envVars))) + } + + if r.secrets != nil && len(r.secrets) != 0 { + output = append(output, fmt.Sprintf("--secrets %s", fmtStringMapToString(r.secrets))) + } + + if r.networkConfiguration.Subnets != nil && len(r.networkConfiguration.Subnets) != 0 { + output = append(output, fmt.Sprintf("--subnets %s", strings.Join(r.networkConfiguration.Subnets, ","))) + } + + if r.networkConfiguration.SecurityGroups != nil && len(r.networkConfiguration.SecurityGroups) != 0 { + output = append(output, fmt.Sprintf("--security-groups %s", strings.Join(r.networkConfiguration.SecurityGroups, ","))) + } + + if r.cluster != "" { + output = append(output, fmt.Sprintf("--cluster %s", r.cluster)) + } + + return strings.Join(output, " \\\n") +} + +func containerInformation(taskDef *awsecs.TaskDefinition, containerName string) (*containerInfo, error) { + image, err := taskDef.Image(containerName) + if err != nil { + return nil, err + } + + entrypoint, err := taskDef.EntryPoint(containerName) + if err != nil { + return nil, err + } + + command, err := taskDef.Command(containerName) + if err != nil { + return nil, err + } + + envVars := make(map[string]string) + for _, envVar := range taskDef.EnvironmentVariables() { + if envVar.Container == containerName { + envVars[envVar.Name] = envVar.Value + } + } + + secrets := make(map[string]string) + for _, secret := range taskDef.Secrets() { + if secret.Container == containerName { + secrets[secret.Name] = secret.ValueFrom + } + } + + return &containerInfo{ + image: image, + entryPoint: entrypoint, + command: command, + envVars: envVars, + secrets: secrets, + }, nil +} + +// This function will format a map to a string as "key1=value1,key2=value2,key3=value3". +func fmtStringMapToString(m map[string]string) string { + var output []string + + // Sort the map so that `output` is consistent and the unit test won't be flaky. + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + output = append(output, fmt.Sprintf("%s=%v", k, m[k])) + } + return strings.Join(output, ",") +} diff --git a/internal/pkg/ecs/run_task_request_test.go b/internal/pkg/ecs/run_task_request_test.go new file mode 100644 index 00000000000..4c979016fef --- /dev/null +++ b/internal/pkg/ecs/run_task_request_test.go @@ -0,0 +1,280 @@ +package ecs + +import ( + "errors" + "testing" + + "github.com/aws/copilot-cli/internal/pkg/ecs/mocks" + + "github.com/aws/aws-sdk-go/aws" + awsecs "github.com/aws/aws-sdk-go/service/ecs" + "github.com/aws/copilot-cli/internal/pkg/aws/ecs" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" +) + +func Test_RunTaskRequestFromECSService(t *testing.T) { + var ( + testCluster = "crowded-cluster" + testService = "good-service" + ) + testCases := map[string]struct { + setUpMock func(m *mocks.MockecsServiceDescriber) + + wantedRunTaskRequest *RunTaskRequest + wantedError error + }{ + "success": { + setUpMock: func(m *mocks.MockecsServiceDescriber) { + m.EXPECT().Service(testCluster, testService).Return(&ecs.Service{ + TaskDefinition: aws.String("task-def"), + }, nil) + m.EXPECT().TaskDefinition("task-def").Return(&ecs.TaskDefinition{ + ExecutionRoleArn: aws.String("execution-role"), + TaskRoleArn: aws.String("task-role"), + ContainerDefinitions: []*awsecs.ContainerDefinition{ + { + Name: aws.String("the-one-and-only-one-container"), + Image: aws.String("beautiful-image"), + EntryPoint: aws.StringSlice([]string{"enter", "here"}), + Command: aws.StringSlice([]string{"do", "not", "enter", "here"}), + Environment: []*awsecs.KeyValuePair{ + { + Name: aws.String("enter"), + Value: aws.String("no"), + }, + { + Name: aws.String("kidding"), + Value: aws.String("yes"), + }, + }, + Secrets: []*awsecs.Secret{ + { + Name: aws.String("truth"), + ValueFrom: aws.String("go-ask-the-wise"), + }, + }, + }, + }, + }, nil) + m.EXPECT().NetworkConfiguration(testCluster, testService).Return(&ecs.NetworkConfiguration{ + AssignPublicIp: "1.2.3.4", + Subnets: []string{"sbn-1", "sbn-2"}, + SecurityGroups: []string{"sg-1", "sg-2"}, + }, nil) + }, + wantedRunTaskRequest: &RunTaskRequest{ + networkConfiguration: ecs.NetworkConfiguration{ + AssignPublicIp: "1.2.3.4", + Subnets: []string{"sbn-1", "sbn-2"}, + SecurityGroups: []string{"sg-1", "sg-2"}, + }, + + executionRole: "execution-role", + taskRole: "task-role", + + containerInfo: containerInfo{ + image: "beautiful-image", + entryPoint: []string{"enter", "here"}, + command: []string{"do", "not", "enter", "here"}, + envVars: map[string]string{ + "enter": "no", + "kidding": "yes", + }, + secrets: map[string]string{ + "truth": "go-ask-the-wise", + }, + }, + + cluster: testCluster, + }, + }, + "unable to retrieve service": { + setUpMock: func(m *mocks.MockecsServiceDescriber) { + m.EXPECT().Service(testCluster, testService).Return(nil, errors.New("some error")) + m.EXPECT().NetworkConfiguration(gomock.Any(), gomock.Any()).AnyTimes() + }, + wantedError: errors.New("retrieve service good-service in cluster crowded-cluster: some error"), + }, + "unable to retrieve task definition": { + setUpMock: func(m *mocks.MockecsServiceDescriber) { + m.EXPECT().Service(testCluster, testService).Return(&ecs.Service{ + TaskDefinition: aws.String("task-def"), + }, nil) + m.EXPECT().TaskDefinition("task-def").Return(nil, errors.New("some error")) + m.EXPECT().NetworkConfiguration(gomock.Any(), gomock.Any()).AnyTimes() + }, + wantedError: errors.New("retrieve task definition task-def: some error"), + }, + "unable to retrieve network configuration": { + setUpMock: func(m *mocks.MockecsServiceDescriber) { + m.EXPECT().Service(gomock.Any(), gomock.Any()).AnyTimes() + m.EXPECT().TaskDefinition(gomock.Any()).AnyTimes() + m.EXPECT().NetworkConfiguration(testCluster, testService).Return(nil, errors.New("some error")) + }, + wantedError: errors.New("retrieve network configuration for service good-service in cluster crowded-cluster: some error"), + }, + "error if found more than one container": { + setUpMock: func(m *mocks.MockecsServiceDescriber) { + m.EXPECT().Service(testCluster, testService).Return(&ecs.Service{ + TaskDefinition: aws.String("task-def"), + }, nil) + m.EXPECT().TaskDefinition("task-def").Return(&ecs.TaskDefinition{ + ExecutionRoleArn: aws.String("execution-role"), + TaskRoleArn: aws.String("task-role"), + ContainerDefinitions: []*awsecs.ContainerDefinition{ + { + Name: aws.String("the-first-container"), + }, + { + Name: aws.String("sad-container"), + }, + }, + }, nil) + m.EXPECT().NetworkConfiguration(gomock.Any(), gomock.Any()).AnyTimes() + }, + wantedError: errors.New("found more than one container in task definition: task-def"), + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := mocks.NewMockecsServiceDescriber(ctrl) + tc.setUpMock(m) + + got, err := RunTaskRequestFromECSService(m, testCluster, testService) + if tc.wantedError != nil { + require.EqualError(t, tc.wantedError, err.Error()) + } else { + require.NoError(t, err) + require.Equal(t, tc.wantedRunTaskRequest, got) + } + }) + } +} + +func Test_RunTaskRequestFromService(t *testing.T) { + var ( + testApp = "app" + testEnv = "env" + testSvc = "svc" + ) + testCases := map[string]struct { + setUpMock func(m *mocks.MockserviceDescriber) + + wantedRunTaskRequest *RunTaskRequest + wantedError error + }{ + "returns RunTaskRequest with service's main container": { + setUpMock: func(m *mocks.MockserviceDescriber) { + m.EXPECT().TaskDefinition(testApp, testEnv, testSvc).Return(&ecs.TaskDefinition{ + ExecutionRoleArn: aws.String("execution-role"), + TaskRoleArn: aws.String("task-role"), + ContainerDefinitions: []*awsecs.ContainerDefinition{ + { + Name: aws.String(testSvc), + Image: aws.String("beautiful-image"), + EntryPoint: aws.StringSlice([]string{"enter", "here"}), + Command: aws.StringSlice([]string{"do", "not", "enter", "here"}), + Environment: []*awsecs.KeyValuePair{ + { + Name: aws.String("enter"), + Value: aws.String("no"), + }, + { + Name: aws.String("kidding"), + Value: aws.String("yes"), + }, + }, + Secrets: []*awsecs.Secret{ + { + Name: aws.String("truth"), + ValueFrom: aws.String("go-ask-the-wise"), + }, + }, + }, + { + Name: aws.String("random-container-that-we-do-not-care"), + }, + }, + }, nil) + m.EXPECT().NetworkConfiguration(testApp, testEnv, testSvc).Return(&ecs.NetworkConfiguration{ + AssignPublicIp: "1.2.3.4", + Subnets: []string{"sbn-1", "sbn-2"}, + SecurityGroups: []string{"sg-1", "sg-2"}, + }, nil) + m.EXPECT().ClusterARN(testApp, testEnv).Return("kamura-village", nil) + }, + wantedRunTaskRequest: &RunTaskRequest{ + networkConfiguration: ecs.NetworkConfiguration{ + AssignPublicIp: "1.2.3.4", + Subnets: []string{"sbn-1", "sbn-2"}, + SecurityGroups: []string{"sg-1", "sg-2"}, + }, + + executionRole: "execution-role", + taskRole: "task-role", + + containerInfo: containerInfo{ + image: "beautiful-image", + entryPoint: []string{"enter", "here"}, + command: []string{"do", "not", "enter", "here"}, + envVars: map[string]string{ + "enter": "no", + "kidding": "yes", + }, + secrets: map[string]string{ + "truth": "go-ask-the-wise", + }, + }, + + cluster: "kamura-village", + }, + }, + "unable to retrieve task definition": { + setUpMock: func(m *mocks.MockserviceDescriber) { + m.EXPECT().TaskDefinition(testApp, testEnv, testSvc).Return(nil, errors.New("some error")) + m.EXPECT().NetworkConfiguration(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + m.EXPECT().ClusterARN(gomock.Any(), gomock.Any()).AnyTimes() + }, + wantedError: errors.New("retrieve task definition for service svc: some error"), + }, + "unable to retrieve network configuration": { + setUpMock: func(m *mocks.MockserviceDescriber) { + m.EXPECT().TaskDefinition(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + m.EXPECT().NetworkConfiguration(testApp, testEnv, testSvc).Return(nil, errors.New("some error")) + m.EXPECT().ClusterARN(gomock.Any(), gomock.Any()).AnyTimes() + }, + wantedError: errors.New("retrieve network configuration for service svc: some error"), + }, + "unable to obtain cluster ARN": { + setUpMock: func(m *mocks.MockserviceDescriber) { + m.EXPECT().TaskDefinition(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + m.EXPECT().NetworkConfiguration(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + m.EXPECT().ClusterARN(testApp, testEnv).Return("", errors.New("some error")) + }, + wantedError: errors.New("retrieve cluster ARN created for environment env in application app: some error"), + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := mocks.NewMockserviceDescriber(ctrl) + tc.setUpMock(m) + + got, err := RunTaskRequestFromService(m, testApp, testEnv, testSvc) + if tc.wantedError != nil { + require.EqualError(t, tc.wantedError, err.Error()) + } else { + require.NoError(t, err) + require.Equal(t, tc.wantedRunTaskRequest, got) + } + }) + } +} diff --git a/internal/pkg/generator/ecs_service.go b/internal/pkg/generator/ecs_service.go deleted file mode 100644 index 3cd6daf2f7e..00000000000 --- a/internal/pkg/generator/ecs_service.go +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Package generator generates a command given an ECS service or a workload. -package generator - -import ( - "fmt" - - "github.com/aws/copilot-cli/internal/pkg/aws/ecs" - - "github.com/aws/aws-sdk-go/aws" -) - -type ecsClient interface { - Service(clusterName, serviceName string) (*ecs.Service, error) - TaskDefinition(taskDefName string) (*ecs.TaskDefinition, error) - NetworkConfiguration(cluster, serviceName string) (*ecs.NetworkConfiguration, error) -} - -// ECSServiceCommandGenerator generates task run command given an ECS service. -type ECSServiceCommandGenerator struct { - Cluster string - Service string - ECSClient ecsClient -} - -// Generate generates a task run command. -func (g ECSServiceCommandGenerator) Generate() (*GenerateCommandOpts, error) { - networkConfig, err := g.ECSClient.NetworkConfiguration(g.Cluster, g.Service) - if err != nil { - return nil, fmt.Errorf("retrieve network configuration for service %s in cluster %s: %w", g.Service, g.Cluster, err) - } - - svc, err := g.ECSClient.Service(g.Cluster, g.Service) - if err != nil { - return nil, fmt.Errorf("retrieve service %s in cluster %s: %w", g.Service, g.Cluster, err) - } - - taskDefNameOrARN := aws.StringValue(svc.TaskDefinition) - taskDef, err := g.ECSClient.TaskDefinition(taskDefNameOrARN) - if err != nil { - return nil, fmt.Errorf("retrieve task definition %s: %w", taskDefNameOrARN, err) - } - - if len(taskDef.ContainerDefinitions) > 1 { - return nil, fmt.Errorf("found more than one container in task definition: %s", taskDefNameOrARN) - } - - containerName := aws.StringValue(taskDef.ContainerDefinitions[0].Name) - containerInfo, err := containerInformation(taskDef, containerName) - if err != nil { - return nil, err - } - - return &GenerateCommandOpts{ - networkConfiguration: *networkConfig, - executionRole: aws.StringValue(taskDef.ExecutionRoleArn), - taskRole: aws.StringValue(taskDef.TaskRoleArn), - containerInfo: *containerInfo, - cluster: g.Cluster, - }, nil -} diff --git a/internal/pkg/generator/ecs_service_test.go b/internal/pkg/generator/ecs_service_test.go deleted file mode 100644 index 016dbe903d9..00000000000 --- a/internal/pkg/generator/ecs_service_test.go +++ /dev/null @@ -1,170 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Package generator generates a command given an ECS service or a workload. -package generator - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/golang/mock/gomock" - - awsecs "github.com/aws/aws-sdk-go/service/ecs" - - "github.com/aws/aws-sdk-go/aws" - - "github.com/aws/copilot-cli/internal/pkg/aws/ecs" - "github.com/aws/copilot-cli/internal/pkg/generator/mocks" -) - -func TestECSServiceCommandGenerator_Generate(t *testing.T) { - var ( - testCluster = "crowded-cluster" - testService = "good-service" - ) - testCases := map[string]struct { - setUpMock func(m *mocks.MockecsClient) - - wantedGenerateCommandOpts *GenerateCommandOpts - wantedError error - }{ - "success": { - setUpMock: func(m *mocks.MockecsClient) { - m.EXPECT().Service(testCluster, testService).Return(&ecs.Service{ - TaskDefinition: aws.String("task-def"), - }, nil) - m.EXPECT().TaskDefinition("task-def").Return(&ecs.TaskDefinition{ - ExecutionRoleArn: aws.String("execution-role"), - TaskRoleArn: aws.String("task-role"), - ContainerDefinitions: []*awsecs.ContainerDefinition{ - { - Name: aws.String("the-one-and-only-one-container"), - Image: aws.String("beautiful-image"), - EntryPoint: aws.StringSlice([]string{"enter", "here"}), - Command: aws.StringSlice([]string{"do", "not", "enter", "here"}), - Environment: []*awsecs.KeyValuePair{ - { - Name: aws.String("enter"), - Value: aws.String("no"), - }, - { - Name: aws.String("kidding"), - Value: aws.String("yes"), - }, - }, - Secrets: []*awsecs.Secret{ - { - Name: aws.String("truth"), - ValueFrom: aws.String("go-ask-the-wise"), - }, - }, - }, - }, - }, nil) - m.EXPECT().NetworkConfiguration(testCluster, testService).Return(&ecs.NetworkConfiguration{ - AssignPublicIp: "1.2.3.4", - Subnets: []string{"sbn-1", "sbn-2"}, - SecurityGroups: []string{"sg-1", "sg-2"}, - }, nil) - }, - wantedGenerateCommandOpts: &GenerateCommandOpts{ - networkConfiguration: ecs.NetworkConfiguration{ - AssignPublicIp: "1.2.3.4", - Subnets: []string{"sbn-1", "sbn-2"}, - SecurityGroups: []string{"sg-1", "sg-2"}, - }, - - executionRole: "execution-role", - taskRole: "task-role", - - containerInfo: containerInfo{ - image: "beautiful-image", - entryPoint: []string{"enter", "here"}, - command: []string{"do", "not", "enter", "here"}, - envVars: map[string]string{ - "enter": "no", - "kidding": "yes", - }, - secrets: map[string]string{ - "truth": "go-ask-the-wise", - }, - }, - - cluster: testCluster, - }, - }, - "unable to retrieve service": { - setUpMock: func(m *mocks.MockecsClient) { - m.EXPECT().Service(testCluster, testService).Return(nil, errors.New("some error")) - m.EXPECT().NetworkConfiguration(gomock.Any(), gomock.Any()).AnyTimes() - }, - wantedError: errors.New("retrieve service good-service in cluster crowded-cluster: some error"), - }, - "unable to retrieve task definition": { - setUpMock: func(m *mocks.MockecsClient) { - m.EXPECT().Service(testCluster, testService).Return(&ecs.Service{ - TaskDefinition: aws.String("task-def"), - }, nil) - m.EXPECT().TaskDefinition("task-def").Return(nil, errors.New("some error")) - m.EXPECT().NetworkConfiguration(gomock.Any(), gomock.Any()).AnyTimes() - }, - wantedError: errors.New("retrieve task definition task-def: some error"), - }, - "unable to retrieve network configuration": { - setUpMock: func(m *mocks.MockecsClient) { - m.EXPECT().Service(gomock.Any(), gomock.Any()).AnyTimes() - m.EXPECT().TaskDefinition(gomock.Any()).AnyTimes() - m.EXPECT().NetworkConfiguration(testCluster, testService).Return(nil, errors.New("some error")) - }, - wantedError: errors.New("retrieve network configuration for service good-service in cluster crowded-cluster: some error"), - }, - "error if found more than one container": { - setUpMock: func(m *mocks.MockecsClient) { - m.EXPECT().Service(testCluster, testService).Return(&ecs.Service{ - TaskDefinition: aws.String("task-def"), - }, nil) - m.EXPECT().TaskDefinition("task-def").Return(&ecs.TaskDefinition{ - ExecutionRoleArn: aws.String("execution-role"), - TaskRoleArn: aws.String("task-role"), - ContainerDefinitions: []*awsecs.ContainerDefinition{ - { - Name: aws.String("the-first-container"), - }, - { - Name: aws.String("sad-container"), - }, - }, - }, nil) - m.EXPECT().NetworkConfiguration(gomock.Any(), gomock.Any()).AnyTimes() - }, - wantedError: errors.New("found more than one container in task definition: task-def"), - }, - } - - for name, tc := range testCases { - t.Run(name, func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - m := mocks.NewMockecsClient(ctrl) - tc.setUpMock(m) - - g := ECSServiceCommandGenerator{ - Cluster: testCluster, - Service: testService, - ECSClient: m, - } - - got, err := g.Generate() - if tc.wantedError != nil { - require.EqualError(t, tc.wantedError, err.Error()) - } else { - require.NoError(t, err) - require.Equal(t, tc.wantedGenerateCommandOpts, got) - } - }) - } -} diff --git a/internal/pkg/generator/generate.go b/internal/pkg/generator/generate.go deleted file mode 100644 index 94ffe7d9afe..00000000000 --- a/internal/pkg/generator/generate.go +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Package generator generates a command given an ECS service or a workload. -package generator - -import ( - "fmt" - "sort" - "strings" - - "github.com/aws/copilot-cli/internal/pkg/aws/ecs" -) - -// GenerateCommandOpts contains information to generate a task run command. -type GenerateCommandOpts struct { - networkConfiguration ecs.NetworkConfiguration - - executionRole string - taskRole string - cluster string - - containerInfo -} - -type containerInfo struct { - image string - entryPoint []string - command []string - envVars map[string]string - secrets map[string]string -} - -func containerInformation(taskDef *ecs.TaskDefinition, containerName string) (*containerInfo, error) { - image, err := taskDef.Image(containerName) - if err != nil { - return nil, err - } - - entrypoint, err := taskDef.EntryPoint(containerName) - if err != nil { - return nil, err - } - - command, err := taskDef.Command(containerName) - if err != nil { - return nil, err - } - - envVars := make(map[string]string) - for _, envVar := range taskDef.EnvironmentVariables() { - if envVar.Container == containerName { - envVars[envVar.Name] = envVar.Value - } - } - - secrets := make(map[string]string) - for _, secret := range taskDef.Secrets() { - if secret.Container == containerName { - secrets[secret.Name] = secret.ValueFrom - } - } - - return &containerInfo{ - image: image, - entryPoint: entrypoint, - command: command, - envVars: envVars, - secrets: secrets, - }, nil -} - -// String stringifies a GenerateCommandOpts. -func (o GenerateCommandOpts) String() string { - output := []string{"copilot task run"} - if o.executionRole != "" { - output = append(output, fmt.Sprintf("--execution-role %s", o.executionRole)) - } - - if o.taskRole != "" { - output = append(output, fmt.Sprintf("--task-role %s", o.taskRole)) - } - - if o.image != "" { - output = append(output, fmt.Sprintf("--image %s", o.image)) - } - - if o.entryPoint != nil { - output = append(output, fmt.Sprintf("--entrypoint %s", fmt.Sprintf("\"%s\"", strings.Join(o.entryPoint, " ")))) - } - - if o.command != nil { - output = append(output, fmt.Sprintf("--command %s", fmt.Sprintf("\"%s\"", strings.Join(o.command, " ")))) - } - - if o.envVars != nil && len(o.envVars) != 0 { - output = append(output, fmt.Sprintf("--env-vars %s", fmtStringMapToString(o.envVars))) - } - - if o.secrets != nil && len(o.secrets) != 0 { - output = append(output, fmt.Sprintf("--secrets %s", fmtStringMapToString(o.secrets))) - } - - if o.networkConfiguration.Subnets != nil && len(o.networkConfiguration.Subnets) != 0 { - output = append(output, fmt.Sprintf("--subnets %s", strings.Join(o.networkConfiguration.Subnets, ","))) - } - - if o.networkConfiguration.SecurityGroups != nil && len(o.networkConfiguration.SecurityGroups) != 0 { - output = append(output, fmt.Sprintf("--security-groups %s", strings.Join(o.networkConfiguration.SecurityGroups, ","))) - } - - if o.cluster != "" { - output = append(output, fmt.Sprintf("--cluster %s", o.cluster)) - } - - return strings.Join(output, " \\\n") -} - -// This function will format a map to a string as "key1=value1,key2=value2,key3=value3". -func fmtStringMapToString(m map[string]string) string { - var output []string - - // Sort the map so that `output` is consistent and the unit test won't be flaky. - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - - for _, k := range keys { - output = append(output, fmt.Sprintf("%s=%v", k, m[k])) - } - return strings.Join(output, ",") -} diff --git a/internal/pkg/generator/generate_test.go b/internal/pkg/generator/generate_test.go deleted file mode 100644 index f64ed5fb07c..00000000000 --- a/internal/pkg/generator/generate_test.go +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Package generator generates a command given an ECS service or a workload. -package generator - -import ( - "testing" - - "github.com/aws/copilot-cli/internal/pkg/aws/ecs" - - "github.com/stretchr/testify/require" -) - -func TestGenerateCommandOpts_String(t *testing.T) { - testCases := map[string]struct { - inGenerateCommandOpts GenerateCommandOpts - wantedCommand string - }{ - "return the correct command string": { - inGenerateCommandOpts: GenerateCommandOpts{ - networkConfiguration: ecs.NetworkConfiguration{ - AssignPublicIp: "1.2.3.4", - Subnets: []string{"sbn-1", "sbn-2"}, - SecurityGroups: []string{"sg-1", "sg-2"}, - }, - executionRole: "good-doggo", - taskRole: "good-kitty", - - containerInfo: containerInfo{ - image: "beautiful-image", - entryPoint: []string{"enter", "from", "here"}, - command: []string{"do", "not", "enter"}, - envVars: map[string]string{ - "weather": "snowy", - "hasHotChocolate": "yes", - }, - secrets: map[string]string{ - "truth": "ask-the-wise", - "lie": "ask-the-villagers", - }, - }, - - cluster: "kamura-village", - }, - wantedCommand: `copilot task run \ ---execution-role good-doggo \ ---task-role good-kitty \ ---image beautiful-image \ ---entrypoint "enter from here" \ ---command "do not enter" \ ---env-vars hasHotChocolate=yes,weather=snowy \ ---secrets lie=ask-the-villagers,truth=ask-the-wise \ ---subnets sbn-1,sbn-2 \ ---security-groups sg-1,sg-2 \ ---cluster kamura-village`, - }, - } - - for name, tc := range testCases { - t.Run(name, func(t *testing.T) { - opts := tc.inGenerateCommandOpts - got := opts.String() - require.Equal(t, tc.wantedCommand, got) - }) - } -} diff --git a/internal/pkg/generator/mocks/mock_ecs_service.go b/internal/pkg/generator/mocks/mock_ecs_service.go deleted file mode 100644 index 29fb26f2b95..00000000000 --- a/internal/pkg/generator/mocks/mock_ecs_service.go +++ /dev/null @@ -1,80 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: ./internal/pkg/generator/ecs_service.go - -// Package mocks is a generated GoMock package. -package mocks - -import ( - reflect "reflect" - - ecs "github.com/aws/copilot-cli/internal/pkg/aws/ecs" - gomock "github.com/golang/mock/gomock" -) - -// MockecsClient is a mock of ecsClient interface. -type MockecsClient struct { - ctrl *gomock.Controller - recorder *MockecsClientMockRecorder -} - -// MockecsClientMockRecorder is the mock recorder for MockecsClient. -type MockecsClientMockRecorder struct { - mock *MockecsClient -} - -// NewMockecsClient creates a new mock instance. -func NewMockecsClient(ctrl *gomock.Controller) *MockecsClient { - mock := &MockecsClient{ctrl: ctrl} - mock.recorder = &MockecsClientMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockecsClient) EXPECT() *MockecsClientMockRecorder { - return m.recorder -} - -// NetworkConfiguration mocks base method. -func (m *MockecsClient) NetworkConfiguration(cluster, serviceName string) (*ecs.NetworkConfiguration, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "NetworkConfiguration", cluster, serviceName) - ret0, _ := ret[0].(*ecs.NetworkConfiguration) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// NetworkConfiguration indicates an expected call of NetworkConfiguration. -func (mr *MockecsClientMockRecorder) NetworkConfiguration(cluster, serviceName interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NetworkConfiguration", reflect.TypeOf((*MockecsClient)(nil).NetworkConfiguration), cluster, serviceName) -} - -// Service mocks base method. -func (m *MockecsClient) Service(clusterName, serviceName string) (*ecs.Service, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Service", clusterName, serviceName) - ret0, _ := ret[0].(*ecs.Service) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Service indicates an expected call of Service. -func (mr *MockecsClientMockRecorder) Service(clusterName, serviceName interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Service", reflect.TypeOf((*MockecsClient)(nil).Service), clusterName, serviceName) -} - -// TaskDefinition mocks base method. -func (m *MockecsClient) TaskDefinition(taskDefName string) (*ecs.TaskDefinition, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "TaskDefinition", taskDefName) - ret0, _ := ret[0].(*ecs.TaskDefinition) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// TaskDefinition indicates an expected call of TaskDefinition. -func (mr *MockecsClientMockRecorder) TaskDefinition(taskDefName interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TaskDefinition", reflect.TypeOf((*MockecsClient)(nil).TaskDefinition), taskDefName) -} diff --git a/internal/pkg/generator/mocks/mock_service.go b/internal/pkg/generator/mocks/mock_service.go deleted file mode 100644 index 4f736d0dea9..00000000000 --- a/internal/pkg/generator/mocks/mock_service.go +++ /dev/null @@ -1,80 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: ./internal/pkg/generator/service.go - -// Package mocks is a generated GoMock package. -package mocks - -import ( - reflect "reflect" - - ecs "github.com/aws/copilot-cli/internal/pkg/aws/ecs" - gomock "github.com/golang/mock/gomock" -) - -// MockecsInformationGetter is a mock of ecsInformationGetter interface. -type MockecsInformationGetter struct { - ctrl *gomock.Controller - recorder *MockecsInformationGetterMockRecorder -} - -// MockecsInformationGetterMockRecorder is the mock recorder for MockecsInformationGetter. -type MockecsInformationGetterMockRecorder struct { - mock *MockecsInformationGetter -} - -// NewMockecsInformationGetter creates a new mock instance. -func NewMockecsInformationGetter(ctrl *gomock.Controller) *MockecsInformationGetter { - mock := &MockecsInformationGetter{ctrl: ctrl} - mock.recorder = &MockecsInformationGetterMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockecsInformationGetter) EXPECT() *MockecsInformationGetterMockRecorder { - return m.recorder -} - -// ClusterARN mocks base method. -func (m *MockecsInformationGetter) ClusterARN(app, env string) (string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ClusterARN", app, env) - ret0, _ := ret[0].(string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ClusterARN indicates an expected call of ClusterARN. -func (mr *MockecsInformationGetterMockRecorder) ClusterARN(app, env interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterARN", reflect.TypeOf((*MockecsInformationGetter)(nil).ClusterARN), app, env) -} - -// NetworkConfiguration mocks base method. -func (m *MockecsInformationGetter) NetworkConfiguration(app, env, svc string) (*ecs.NetworkConfiguration, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "NetworkConfiguration", app, env, svc) - ret0, _ := ret[0].(*ecs.NetworkConfiguration) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// NetworkConfiguration indicates an expected call of NetworkConfiguration. -func (mr *MockecsInformationGetterMockRecorder) NetworkConfiguration(app, env, svc interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NetworkConfiguration", reflect.TypeOf((*MockecsInformationGetter)(nil).NetworkConfiguration), app, env, svc) -} - -// TaskDefinition mocks base method. -func (m *MockecsInformationGetter) TaskDefinition(app, env, svc string) (*ecs.TaskDefinition, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "TaskDefinition", app, env, svc) - ret0, _ := ret[0].(*ecs.TaskDefinition) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// TaskDefinition indicates an expected call of TaskDefinition. -func (mr *MockecsInformationGetterMockRecorder) TaskDefinition(app, env, svc interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TaskDefinition", reflect.TypeOf((*MockecsInformationGetter)(nil).TaskDefinition), app, env, svc) -} diff --git a/internal/pkg/generator/service.go b/internal/pkg/generator/service.go deleted file mode 100644 index c9c8d2079c0..00000000000 --- a/internal/pkg/generator/service.go +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Package generator generates a command given an ECS service or a workload. -package generator - -import ( - "fmt" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/copilot-cli/internal/pkg/aws/ecs" -) - -type ecsInformationGetter interface { - TaskDefinition(app, env, svc string) (*ecs.TaskDefinition, error) - NetworkConfiguration(app, env, svc string) (*ecs.NetworkConfiguration, error) - ClusterARN(app, env string) (string, error) -} - -// ServiceCommandGenerator generates task run command given a Copilot service. -type ServiceCommandGenerator struct { - App string - Env string - Service string - ECSInformationGetter ecsInformationGetter -} - -// Generate generates a task run command. -func (g ServiceCommandGenerator) Generate() (*GenerateCommandOpts, error) { - networkConfig, err := g.ECSInformationGetter.NetworkConfiguration(g.App, g.Env, g.Service) - if err != nil { - return nil, fmt.Errorf("retrieve network configuration for service %s: %w", g.Service, err) - } - - cluster, err := g.ECSInformationGetter.ClusterARN(g.App, g.Env) - if err != nil { - return nil, fmt.Errorf("retrieve cluster ARN created for environment %s in application %s: %w", g.Env, g.App, err) - } - - taskDef, err := g.ECSInformationGetter.TaskDefinition(g.App, g.Env, g.Service) - if err != nil { - return nil, fmt.Errorf("retrieve task definition for service %s: %w", g.Service, err) - } - - containerName := g.Service // NOTE: refer to workload's CloudFormation template. The container name is set to be the workload's name. - containerInfo, err := containerInformation(taskDef, containerName) - if err != nil { - return nil, err - } - - return &GenerateCommandOpts{ - networkConfiguration: *networkConfig, - executionRole: aws.StringValue(taskDef.ExecutionRoleArn), - taskRole: aws.StringValue(taskDef.TaskRoleArn), - containerInfo: *containerInfo, - cluster: cluster, - }, nil -} diff --git a/internal/pkg/generator/service_test.go b/internal/pkg/generator/service_test.go deleted file mode 100644 index 5c2692c134d..00000000000 --- a/internal/pkg/generator/service_test.go +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Package generator generates a command given an ECS service or a workload. -package generator - -import ( - "errors" - "testing" - - "github.com/aws/aws-sdk-go/aws" - awsecs "github.com/aws/aws-sdk-go/service/ecs" - "github.com/aws/copilot-cli/internal/pkg/aws/ecs" - - "github.com/aws/copilot-cli/internal/pkg/generator/mocks" - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/require" -) - -func TestServiceCommandGenerator_Generate(t *testing.T) { - var ( - testApp = "app" - testEnv = "env" - testSvc = "svc" - ) - testCases := map[string]struct { - setUpMock func(m *mocks.MockecsInformationGetter) - - wantedGenerateCommandOpts *GenerateCommandOpts - wantedError error - }{ - "returns generateCommandOpts with service's main container": { - setUpMock: func(m *mocks.MockecsInformationGetter) { - m.EXPECT().TaskDefinition(testApp, testEnv, testSvc).Return(&ecs.TaskDefinition{ - ExecutionRoleArn: aws.String("execution-role"), - TaskRoleArn: aws.String("task-role"), - ContainerDefinitions: []*awsecs.ContainerDefinition{ - { - Name: aws.String(testSvc), - Image: aws.String("beautiful-image"), - EntryPoint: aws.StringSlice([]string{"enter", "here"}), - Command: aws.StringSlice([]string{"do", "not", "enter", "here"}), - Environment: []*awsecs.KeyValuePair{ - { - Name: aws.String("enter"), - Value: aws.String("no"), - }, - { - Name: aws.String("kidding"), - Value: aws.String("yes"), - }, - }, - Secrets: []*awsecs.Secret{ - { - Name: aws.String("truth"), - ValueFrom: aws.String("go-ask-the-wise"), - }, - }, - }, - { - Name: aws.String("random-container-that-we-do-not-care"), - }, - }, - }, nil) - m.EXPECT().NetworkConfiguration(testApp, testEnv, testSvc).Return(&ecs.NetworkConfiguration{ - AssignPublicIp: "1.2.3.4", - Subnets: []string{"sbn-1", "sbn-2"}, - SecurityGroups: []string{"sg-1", "sg-2"}, - }, nil) - m.EXPECT().ClusterARN(testApp, testEnv).Return("kamura-village", nil) - }, - wantedGenerateCommandOpts: &GenerateCommandOpts{ - networkConfiguration: ecs.NetworkConfiguration{ - AssignPublicIp: "1.2.3.4", - Subnets: []string{"sbn-1", "sbn-2"}, - SecurityGroups: []string{"sg-1", "sg-2"}, - }, - - executionRole: "execution-role", - taskRole: "task-role", - - containerInfo: containerInfo{ - image: "beautiful-image", - entryPoint: []string{"enter", "here"}, - command: []string{"do", "not", "enter", "here"}, - envVars: map[string]string{ - "enter": "no", - "kidding": "yes", - }, - secrets: map[string]string{ - "truth": "go-ask-the-wise", - }, - }, - - cluster: "kamura-village", - }, - }, - "unable to retrieve task definition": { - setUpMock: func(m *mocks.MockecsInformationGetter) { - m.EXPECT().TaskDefinition(testApp, testEnv, testSvc).Return(nil, errors.New("some error")) - m.EXPECT().NetworkConfiguration(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - m.EXPECT().ClusterARN(gomock.Any(), gomock.Any()).AnyTimes() - }, - wantedError: errors.New("retrieve task definition for service svc: some error"), - }, - "unable to retrieve network configuration": { - setUpMock: func(m *mocks.MockecsInformationGetter) { - m.EXPECT().TaskDefinition(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - m.EXPECT().NetworkConfiguration(testApp, testEnv, testSvc).Return(nil, errors.New("some error")) - m.EXPECT().ClusterARN(gomock.Any(), gomock.Any()).AnyTimes() - }, - wantedError: errors.New("retrieve network configuration for service svc: some error"), - }, - "unable to obtain cluster ARN": { - setUpMock: func(m *mocks.MockecsInformationGetter) { - m.EXPECT().TaskDefinition(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - m.EXPECT().NetworkConfiguration(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - m.EXPECT().ClusterARN(testApp, testEnv).Return("", errors.New("some error")) - }, - wantedError: errors.New("retrieve cluster ARN created for environment env in application app: some error"), - }, - } - - for name, tc := range testCases { - t.Run(name, func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - m := mocks.NewMockecsInformationGetter(ctrl) - tc.setUpMock(m) - - g := ServiceCommandGenerator{ - App: testApp, - Env: testEnv, - Service: testSvc, - - ECSInformationGetter: m, - } - - got, err := g.Generate() - if tc.wantedError != nil { - require.EqualError(t, tc.wantedError, err.Error()) - } else { - require.NoError(t, err) - require.Equal(t, tc.wantedGenerateCommandOpts, got) - } - }) - } -}