diff --git a/internal/pkg/cli/flag.go b/internal/pkg/cli/flag.go index d6f0ade2f61..acabc0e0e2b 100644 --- a/internal/pkg/cli/flag.go +++ b/internal/pkg/cli/flag.go @@ -67,6 +67,7 @@ const ( imageFlag = "image" taskRoleFlag = "task-role" executionRoleFlag = "execution-role" + clusterFlag = "cluster" subnetsFlag = "subnets" securityGroupsFlag = "security-groups" envVarsFlag = "env-vars" @@ -133,6 +134,8 @@ Mutually exclusive with -%s, --%s`, imageFlagShort, imageFlag) wkldTypeFlagDescription = fmt.Sprintf(`Type of job or svc to create. Must be one of: %s`, strings.Join(template.QuoteSliceFunc(manifest.WorkloadTypes), ", ")) + clusterFlagDescription = fmt.Sprintf(`Optional. The short name or full ARN of the cluster to run the task in. +Cannot be specified with '%s', '%s' or '%s'.`, appFlag, envFlag, taskDefaultFlag) subnetsFlagDescription = fmt.Sprintf(`Optional. The subnet IDs for the task to use. Can be specified multiple times. Cannot be specified with '%s', '%s' or '%s'.`, appFlag, envFlag, taskDefaultFlag) securityGroupsFlagDescription = fmt.Sprintf(`Optional. The security group IDs for the task to use. Can be specified multiple times. diff --git a/internal/pkg/cli/task_run.go b/internal/pkg/cli/task_run.go index 53ae35e984c..84b10863fa6 100644 --- a/internal/pkg/cli/task_run.go +++ b/internal/pkg/cli/task_run.go @@ -79,11 +79,12 @@ type runTaskVars struct { taskRole string executionRole string - subnets []string - securityGroups []string - env string - appName string - useDefaultSubnets bool + cluster string + subnets []string + securityGroups []string + env string + appName string + useDefaultSubnetsAndCluster bool envVars map[string]string secrets map[string]string @@ -200,10 +201,11 @@ func (o *runTaskOpts) configureRunner() (taskRunner, error) { }, nil } - return &task.NetworkConfigRunner{ + return &task.ConfigRunner{ Count: o.count, GroupName: o.groupName, + Cluster: o.cluster, Subnets: o.subnets, SecurityGroups: o.securityGroups, @@ -273,6 +275,10 @@ func (o *runTaskOpts) Validate() error { } } + if err := o.validateFlagsWithCluster(); err != nil { + return err + } + if err := o.validateFlagsWithDefaultCluster(); err != nil { return err } @@ -300,8 +306,28 @@ func (o *runTaskOpts) Validate() error { return nil } +func (o *runTaskOpts) validateFlagsWithCluster() error { + if o.cluster == "" { + return nil + } + + if o.appName != "" { + return fmt.Errorf("cannot specify both `--app` and `--cluster`") + } + + if o.env != "" { + return fmt.Errorf("cannot specify both `--env` and `--cluster`") + } + + if o.useDefaultSubnetsAndCluster { + return fmt.Errorf("cannot specify both `--default` and `--cluster`") + } + + return nil +} + func (o *runTaskOpts) validateFlagsWithDefaultCluster() error { - if !o.useDefaultSubnets { + if !o.useDefaultSubnetsAndCluster { return nil } @@ -325,7 +351,7 @@ func (o *runTaskOpts) validateFlagsWithSubnets() error { return nil } - if o.useDefaultSubnets { + if o.useDefaultSubnetsAndCluster { return fmt.Errorf("cannot specify both `--subnets` and `--default`") } @@ -371,7 +397,7 @@ func (o *runTaskOpts) Ask() error { 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.useDefaultSubnets || (o.securityGroups != nil && o.subnets == nil) + useDefault := o.useDefaultSubnetsAndCluster || (o.securityGroups != nil && o.subnets == nil && o.cluster == "") useConfig := o.subnets != nil // if user hasn't specified that they want to use the default subnets, and that they didn't provide specific subnets @@ -399,7 +425,7 @@ func (o *runTaskOpts) Execute() error { return err } - if o.env == "" { + if o.env == "" && o.cluster == "" { hasDefaultCluster, err := o.defaultClusterGetter.HasDefaultCluster() if err != nil { return fmt.Errorf(`find "default" cluster to deploy the task to: %v`, err) @@ -700,9 +726,10 @@ Run a task with a command. cmd.Flags().StringVar(&vars.appName, appFlag, "", taskAppFlagDescription) cmd.Flags().StringVar(&vars.env, envFlag, "", taskEnvFlagDescription) + cmd.Flags().StringVar(&vars.cluster, clusterFlag, "", clusterFlagDescription) cmd.Flags().StringSliceVar(&vars.subnets, subnetsFlag, nil, subnetsFlagDescription) cmd.Flags().StringSliceVar(&vars.securityGroups, securityGroupsFlag, nil, securityGroupsFlagDescription) - cmd.Flags().BoolVar(&vars.useDefaultSubnets, taskDefaultFlag, false, taskRunDefaultFlagDescription) + cmd.Flags().BoolVar(&vars.useDefaultSubnetsAndCluster, taskDefaultFlag, false, taskRunDefaultFlagDescription) cmd.Flags().StringToStringVar(&vars.envVars, envVarsFlag, nil, envVarsFlagDescription) cmd.Flags().StringToStringVar(&vars.secrets, secretsFlag, nil, secretsFlagDescription) diff --git a/internal/pkg/cli/task_run_test.go b/internal/pkg/cli/task_run_test.go index 7beab9c7db3..b0c155e45d4 100644 --- a/internal/pkg/cli/task_run_test.go +++ b/internal/pkg/cli/task_run_test.go @@ -51,6 +51,7 @@ func TestTaskRunOpts_Validate(t *testing.T) { inTaskRole string inEnv string + inCluster string inSubnets []string inSecurityGroups []string @@ -277,6 +278,30 @@ func TestTaskRunOpts_Validate(t *testing.T) { wantedError: errors.New("cannot specify both `--subnets` and `--default`"), }, + "both cluster and default specified": { + basicOpts: defaultOpts, + + inDefault: true, + inCluster: "special-cluster", + + wantedError: errors.New("cannot specify both `--default` and `--cluster`"), + }, + "both cluster and application specified": { + basicOpts: defaultOpts, + + inCluster: "special-cluster", + appName: "my-app", + + wantedError: errors.New("cannot specify both `--app` and `--cluster`"), + }, + "both cluster and environment specified": { + basicOpts: defaultOpts, + + inCluster: "special-cluster", + inEnv: "my-env", + + wantedError: errors.New("cannot specify both `--env` and `--cluster`"), + }, } for name, tc := range testCases { @@ -288,22 +313,23 @@ func TestTaskRunOpts_Validate(t *testing.T) { opts := runTaskOpts{ runTaskVars: runTaskVars{ - appName: tc.appName, - count: tc.inCount, - cpu: tc.inCPU, - memory: tc.inMemory, - groupName: tc.inName, - image: tc.inImage, - env: tc.inEnv, - taskRole: tc.inTaskRole, - subnets: tc.inSubnets, - securityGroups: tc.inSecurityGroups, - dockerfilePath: tc.inDockerfilePath, - envVars: tc.inEnvVars, - secrets: tc.inSecrets, - command: tc.inCommand, - entrypoint: tc.inEntryPoint, - useDefaultSubnets: tc.inDefault, + appName: tc.appName, + count: tc.inCount, + cpu: tc.inCPU, + memory: tc.inMemory, + groupName: tc.inName, + image: tc.inImage, + env: tc.inEnv, + taskRole: tc.inTaskRole, + cluster: tc.inCluster, + subnets: tc.inSubnets, + securityGroups: tc.inSecurityGroups, + dockerfilePath: tc.inDockerfilePath, + envVars: tc.inEnvVars, + secrets: tc.inSecrets, + command: tc.inCommand, + entrypoint: tc.inEntryPoint, + useDefaultSubnetsAndCluster: tc.inDefault, }, isDockerfileSet: tc.isDockerfileSet, @@ -319,7 +345,6 @@ func TestTaskRunOpts_Validate(t *testing.T) { } err := opts.Validate() - if tc.wantedError != nil { require.EqualError(t, tc.wantedError, err.Error()) } else { @@ -333,6 +358,7 @@ func TestTaskRunOpts_Ask(t *testing.T) { testCases := map[string]struct { inName string + inCluster string inSubnets []string inSecurityGroups []string @@ -437,6 +463,16 @@ func TestTaskRunOpts_Ask(t *testing.T) { m.EXPECT().Environment(taskRunEnvPrompt, gomock.Any(), gomock.Any(), appEnvOptionNone).AnyTimes() }, }, + "don't prompt for app if cluster is specified": { + inCluster: "cluster-1", + mockPrompt: func(m *mocks.Mockprompter) { + m.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + }, + mockSel: func(m *mocks.MockappEnvSelector) { + m.EXPECT().Application(taskRunAppPrompt, gomock.Any(), gomock.Any()).AnyTimes() + m.EXPECT().Environment(taskRunEnvPrompt, gomock.Any(), gomock.Any(), appEnvOptionNone).Times(0) + }, + }, "don't prompt for env if subnets are specified": { inSubnets: []string{"subnet-1"}, mockPrompt: func(m *mocks.Mockprompter) { @@ -447,6 +483,16 @@ func TestTaskRunOpts_Ask(t *testing.T) { m.EXPECT().Environment(taskRunEnvPrompt, gomock.Any(), gomock.Any(), appEnvOptionNone).Times(0) }, }, + "don't prompt for env if cluster is specified": { + inCluster: "cluster-1", + mockPrompt: func(m *mocks.Mockprompter) { + m.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + }, + mockSel: func(m *mocks.MockappEnvSelector) { + m.EXPECT().Application(taskRunAppPrompt, gomock.Any(), gomock.Any()).AnyTimes() + m.EXPECT().Environment(taskRunEnvPrompt, gomock.Any(), gomock.Any(), appEnvOptionNone).Times(0) + }, + }, "don't prompt for app if security groups are specified": { inSecurityGroups: []string{"sg-1"}, mockPrompt: func(m *mocks.Mockprompter) { @@ -528,12 +574,13 @@ func TestTaskRunOpts_Ask(t *testing.T) { opts := runTaskOpts{ runTaskVars: runTaskVars{ - appName: tc.appName, - groupName: tc.inName, - env: tc.inEnv, - useDefaultSubnets: tc.inDefault, - subnets: tc.inSubnets, - securityGroups: tc.inSecurityGroups, + appName: tc.appName, + groupName: tc.inName, + env: tc.inEnv, + useDefaultSubnetsAndCluster: tc.inDefault, + subnets: tc.inSubnets, + securityGroups: tc.inSecurityGroups, + cluster: tc.inCluster, }, sel: mockSel, } diff --git a/internal/pkg/task/config_runner.go b/internal/pkg/task/config_runner.go index bd75f7242f1..eb74a40a946 100644 --- a/internal/pkg/task/config_runner.go +++ b/internal/pkg/task/config_runner.go @@ -14,13 +14,17 @@ const ( fmtErrDefaultSubnets = "get default subnet IDs: %w" ) -// NetworkConfigRunner runs an Amazon ECS task in the subnets, security groups, and the default cluster. -type NetworkConfigRunner struct { +// ConfigRunner runs an Amazon ECS task in the subnets, security groups, and cluster. +// It uses the default subnets and the default cluster if the corresponding field is empty. +type ConfigRunner struct { // Count of the tasks to be launched. Count int // Group Name of the tasks that use the same task definition. GroupName string + // The ARN of the cluster to run the task. + Cluster string + // Network configuration Subnets []string SecurityGroups []string @@ -33,18 +37,22 @@ type NetworkConfigRunner struct { VPCGetter VPCGetter } -// Run runs tasks in the subnets and the security groups, and returns the tasks. +// Run runs tasks given subnets, security groups and the cluster, and returns the tasks. // If subnets are not provided, it uses the default subnets. -func (r *NetworkConfigRunner) Run() ([]*Task, error) { +// If cluster is not provided, it uses the default cluster. +func (r *ConfigRunner) Run() ([]*Task, error) { if err := r.validateDependencies(); err != nil { return nil, err } - cluster, err := r.ClusterGetter.DefaultCluster() - if err != nil { - return nil, &errGetDefaultCluster{ - parentErr: err, + if r.Cluster == "" { + cluster, err := r.ClusterGetter.DefaultCluster() + if err != nil { + return nil, &errGetDefaultCluster{ + parentErr: err, + } } + r.Cluster = cluster } if r.Subnets == nil { @@ -55,12 +63,11 @@ func (r *NetworkConfigRunner) Run() ([]*Task, error) { if len(subnets) == 0 { return nil, errNoSubnetFound } - r.Subnets = subnets } ecsTasks, err := r.Starter.RunTask(ecs.RunTaskInput{ - Cluster: cluster, + Cluster: r.Cluster, Count: r.Count, Subnets: r.Subnets, SecurityGroups: r.SecurityGroups, @@ -77,7 +84,7 @@ func (r *NetworkConfigRunner) Run() ([]*Task, error) { return convertECSTasks(ecsTasks), nil } -func (r *NetworkConfigRunner) validateDependencies() error { +func (r *ConfigRunner) validateDependencies() error { if r.ClusterGetter == nil { return errClusterGetterNil } diff --git a/internal/pkg/task/config_runner_test.go b/internal/pkg/task/config_runner_test.go index 0bb5902c85b..192bb189281 100644 --- a/internal/pkg/task/config_runner_test.go +++ b/internal/pkg/task/config_runner_test.go @@ -47,6 +47,7 @@ func TestNetworkConfigRunner_Run(t *testing.T) { count int groupName string + cluster string subnets []string securityGroups []string @@ -210,6 +211,38 @@ func TestNetworkConfigRunner_Run(t *testing.T) { }, }, }, + "successfully kick off task with specified cluster": { + count: 1, + groupName: "my-task", + + cluster: "special-cluster", + subnets: []string{"subnet-1", "subnet-2"}, + securityGroups: []string{"sg-1", "sg-2"}, + + mockClusterGetter: func(m *mocks.MockDefaultClusterGetter) { + m.EXPECT().DefaultCluster().Times(0) + }, + MockVPCGetter: func(m *mocks.MockVPCGetter) { + m.EXPECT().SubnetIDs([]ec2.Filter{ec2.FilterForDefaultVPCSubnets}).Times(0) + }, + mockStarter: func(m *mocks.MockRunner) { + m.EXPECT().RunTask(ecs.RunTaskInput{ + Cluster: "special-cluster", + Count: 1, + Subnets: []string{"subnet-1", "subnet-2"}, + SecurityGroups: []string{"sg-1", "sg-2"}, + TaskFamilyName: taskFamilyName("my-task"), + StartedBy: startedBy, + }).Return([]*ecs.Task{&taskWithENI}, nil) + }, + + wantedTasks: []*Task{ + { + TaskARN: "task-1", + ENI: "eni-1", + }, + }, + }, } for name, tc := range testCases { @@ -225,10 +258,11 @@ func TestNetworkConfigRunner_Run(t *testing.T) { tc.mockClusterGetter(mockClusterGetter) tc.mockStarter(mockStarter) - task := &NetworkConfigRunner{ + task := &ConfigRunner{ Count: tc.count, GroupName: tc.groupName, + Cluster: tc.cluster, Subnets: tc.subnets, SecurityGroups: tc.securityGroups, diff --git a/site/content/docs/commands/task-run.md b/site/content/docs/commands/task-run.md index 319ff4197f5..9c8fc1fb6a8 100644 --- a/site/content/docs/commands/task-run.md +++ b/site/content/docs/commands/task-run.md @@ -22,6 +22,7 @@ Generally, the steps involved in task run are: ``` --app string Optional. Name of the application. Cannot be specified with 'default', 'subnets' or 'security-groups' + --cluster string Optional. The short name or full ARN of the cluster to run the task in. --command string Optional. The command that is passed to "docker run" to override the default command. --count int Optional. The number of tasks to set up. (default 1) --cpu int Optional. The number of CPU units to reserve for each task. (default 256)