diff --git a/README.md b/README.md index d535ce351..c5f05b14e 100644 --- a/README.md +++ b/README.md @@ -434,6 +434,13 @@ run_params: subnets: array of strings // These should be in the same VPC and Availability Zone as your instance security_groups: array of strings // These should be in the same VPC as your instance assign_public_ip: string // supported values: ENABLED or DISABLED + task_placement: + strategy: + - type: string // Valid values: "spread"|"binpack"|"random" + field: string // Not valid if type is "random" + constraints: + - type: string // Valid values: "memberOf"|"distinctInstance" + expression: string // Not valid if type is "distinctInstance" ``` **Version** @@ -478,6 +485,17 @@ Currently, the only parameter supported under `run_params` is `network_configura * `subnets`: list of subnet ids used to launch tasks. ***NOTE*** These should be in the same VPC and availability zone as the instances on which you wish to launch your tasks. * `security_groups`: list of securtiy-group ids used to launch tasks. ***NOTE*** These should be in the same VPC as the instances on which you wish to launch your tasks. * `assign_public_ip`: supported values for this field are either "ENABLED" or "DISABLED". This field is *only* used for tasks launched with Fargate launch type. If this field is present in tasks with network configuration launched with EC2 launch type, the request will fail. +* `task_placement` is an optional field with `EC2` launch-type only (it is *not* valid for `FARGATE`). It has two subfields: + * `strategy`: A list of objects, with two keys. Valid keys are `type` and `field`. + * `type`: Valid values are `random`, `binpack`, or `spread`. If `random` is specified, the `field` key is not necessary. + * `field`: Valid values depend on the strategy type. + * For `spread`, valid values are `instanceId`, `host`, or attribute key/value pairs, e.g. `attribute:ecs.instance-type =~ t2.*` + * For "binpack", valid values are "cpu" or "memory". + * `constraint`: A list of objects, with two keys. Valid keys are `type` and `expression`. + * `type`: Valid values are `distinctInstance` and `memberOf`. If `distinctInstance` is specified, the `expression key is not necessary. + * `expression`: When `type` is `memberOf`, valid values are key/value pairs for attributes or task groups, e.g. `task:group == databases` or `attribute:color =~ green`. + +For more information on task placement, see [Amazon ECS TaskPlacement] (https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-placement.html). Example `ecs-params.yml` file: @@ -537,6 +555,24 @@ run_params: assign_public_ip: ENABLED ``` +Example `ecs-params.yml` with task placement: + +``` +version: 1 +run_params: + task_placement: + strategy: + - field: memory + type: binpack + - field: attribute:ecs.availability-zone + type: spread + - type: random + constraints: + - expression: attribute:ecs.instance-type =~ t2.* + type: memberOf + - type: distinctInstance` +``` + You can then start a task by calling: ``` ecs-cli compose --ecs-params my-ecs-params.yml up diff --git a/ecs-cli/modules/cli/compose/entity/entity_helper.go b/ecs-cli/modules/cli/compose/entity/entity_helper.go index 5a29a9602..a77f319d3 100644 --- a/ecs-cli/modules/cli/compose/entity/entity_helper.go +++ b/ecs-cli/modules/cli/compose/entity/entity_helper.go @@ -85,6 +85,8 @@ func SetupTaskDefinitionCache() cache.Cache { // GetOrCreateTaskDefinition gets the task definition from cache if present, else // creates it in ECS and persists in a local cache. It also sets the latest // taskDefinition to the current instance of task +// TODO: convert to method on entity, since it changes state of entity? +// Also, since this is called before other task/service API calls, might be good to add Fargate validation here func GetOrCreateTaskDefinition(entity ProjectEntity) (*ecs.TaskDefinition, error) { taskDefinition := entity.TaskDefinition() log.WithFields(log.Fields{ diff --git a/ecs-cli/modules/cli/compose/entity/service/service.go b/ecs-cli/modules/cli/compose/entity/service/service.go index 86b024e15..2c887c786 100644 --- a/ecs-cli/modules/cli/compose/entity/service/service.go +++ b/ecs-cli/modules/cli/compose/entity/service/service.go @@ -364,12 +364,22 @@ func (s *Service) EntityType() types.Type { func (s *Service) buildCreateServiceInput(serviceName, taskDefName string) (*ecs.CreateServiceInput, error) { launchType := s.Context().CommandConfig.LaunchType cluster := s.Context().CommandConfig.Cluster + ecsParams := s.ecsContext.ECSParams - networkConfig, err := composeutils.ConvertToECSNetworkConfiguration(s.ecsContext.ECSParams) + networkConfig, err := composeutils.ConvertToECSNetworkConfiguration(ecsParams) + if err != nil { + return nil, err + } + placementConstraints, err := composeutils.ConvertToECSPlacementConstraints(ecsParams) + if err != nil { + return nil, err + } + placementStrategy, err := composeutils.ConvertToECSPlacementStrategy(ecsParams) if err != nil { return nil, err } + // NOTE: this validation is not useful if called after GetOrCreateTaskDefinition() if err = entity.ValidateFargateParams(s.Context().ECSParams, launchType); err != nil { return nil, err } @@ -396,6 +406,14 @@ func (s *Service) buildCreateServiceInput(serviceName, taskDefName string) (*ecs createServiceInput.NetworkConfiguration = networkConfig } + if placementConstraints != nil { + createServiceInput.PlacementConstraints = placementConstraints + } + + if placementStrategy != nil { + createServiceInput.PlacementStrategy = placementStrategy + } + if launchType != "" { createServiceInput.LaunchType = aws.String(launchType) } diff --git a/ecs-cli/modules/cli/compose/entity/service/service_test.go b/ecs-cli/modules/cli/compose/entity/service/service_test.go index bd5cb8e73..ee395b8ed 100644 --- a/ecs-cli/modules/cli/compose/entity/service/service_test.go +++ b/ecs-cli/modules/cli/compose/entity/service/service_test.go @@ -205,6 +205,69 @@ func TestCreateEC2Explicitly(t *testing.T) { ) } +func TestCreateWithTaskPlacement(t *testing.T) { + flagSet := flag.NewFlagSet("ecs-cli-up", 0) + + createServiceTest( + t, + flagSet, + &config.CommandConfig{}, + ecsParamsWithTaskPlacement(), + func(input *ecs.CreateServiceInput) { + placementConstraints := input.PlacementConstraints + placementStrategy := input.PlacementStrategy + expectedConstraints := []*ecs.PlacementConstraint{ + { + Type: aws.String("distinctInstance"), + }, { + Expression: aws.String("attribute:ecs.instance-type =~ t2.*"), + Type: aws.String("memberOf"), + }, + } + expectedStrategy := []*ecs.PlacementStrategy{ + { + Type: aws.String("random"), + }, { + Field: aws.String("instanceId"), + Type: aws.String("binpack"), + }, + } + + assert.Len(t, placementConstraints, 2) + assert.Equal(t, expectedConstraints, placementConstraints, "Expected Placement Constraints to match") + assert.Len(t, placementStrategy, 2) + assert.Equal(t, expectedStrategy, placementStrategy, "Expected Placement Strategy to match") + }, + ) +} + +func ecsParamsWithTaskPlacement() *utils.ECSParams { + return &utils.ECSParams{ + RunParams: utils.RunParams{ + TaskPlacement: utils.TaskPlacement{ + Constraints: []utils.Constraint{ + utils.Constraint{ + Type: ecs.PlacementConstraintTypeDistinctInstance, + }, + utils.Constraint{ + Expression: "attribute:ecs.instance-type =~ t2.*", + Type: ecs.PlacementConstraintTypeMemberOf, + }, + }, + Strategies: []utils.Strategy{ + utils.Strategy{ + Type: ecs.PlacementStrategyTypeRandom, + }, + utils.Strategy{ + Field: "instanceId", + Type: ecs.PlacementStrategyTypeBinpack, + }, + }, + }, + }, + } +} + // Specifies TargeGroupArn to test ALB func TestCreateWithALB(t *testing.T) { targetGroupArn := "targetGroupArn" diff --git a/ecs-cli/modules/cli/compose/entity/task/task.go b/ecs-cli/modules/cli/compose/entity/task/task.go index 80bf82f8a..2ff4bb84b 100644 --- a/ecs-cli/modules/cli/compose/entity/task/task.go +++ b/ecs-cli/modules/cli/compose/entity/task/task.go @@ -17,6 +17,7 @@ import ( "github.com/aws/amazon-ecs-cli/ecs-cli/modules/cli/compose/context" "github.com/aws/amazon-ecs-cli/ecs-cli/modules/cli/compose/entity" "github.com/aws/amazon-ecs-cli/ecs-cli/modules/cli/compose/entity/types" + "github.com/aws/amazon-ecs-cli/ecs-cli/modules/commands/flags" "github.com/aws/amazon-ecs-cli/ecs-cli/modules/utils" "github.com/aws/amazon-ecs-cli/ecs-cli/modules/utils/cache" composeutils "github.com/aws/amazon-ecs-cli/ecs-cli/modules/utils/compose" @@ -99,7 +100,8 @@ func (t *Task) Start() error { // if count of running tasks = 0, starts 1 // if count != 0, and the task definitions differed, then its stops the old ones and starts the new ones func (t *Task) Up() error { - return t.up(true) + updateTasks := t.Context().CLIContext.Bool(flags.ForceUpdateFlag) + return t.up(updateTasks) } // Info returns a formatted list of containers (running and stopped) in the current cluster @@ -108,11 +110,12 @@ func (t *Task) Info(filterLocal bool) (project.InfoSet, error) { return entity.Info(t, filterLocal) } -// Scale finds out the current count of running tasks for this project and scales to the desired count +// Scale finds out the current count of running tasks for this project and scales to the desired count. +// Any run params specified will be taken into account. // if desired = current, noop // if desired > current, stops the extra ones // if desired < current, start new ones (also if current was 0, create a new task definition) -func (t *Task) Scale(expectedCount int) error { +func (t *Task) Scale(desiredCount int) error { ecsTasks, err := entity.CollectTasksWithStatus(t, ecs.DesiredStatusRunning, true) if err != nil { return err @@ -120,7 +123,7 @@ func (t *Task) Scale(expectedCount int) error { observedCount := len(ecsTasks) - if expectedCount == observedCount { + if desiredCount == observedCount { // NoOp log.WithFields(log.Fields{ "countOfRunningTasks": observedCount, @@ -129,9 +132,9 @@ func (t *Task) Scale(expectedCount int) error { return nil } - // running more than expected, stop the tasks - if expectedCount < observedCount { - diff := observedCount - expectedCount + // running more than desired, stop the extra tasks + if desiredCount < observedCount { + diff := observedCount - desiredCount ecsTasksToStop := []*ecs.Task{} for i := 0; i < diff; i++ { ecsTasksToStop = append(ecsTasksToStop, ecsTasks[i]) @@ -139,8 +142,8 @@ func (t *Task) Scale(expectedCount int) error { return t.stopTasks(ecsTasksToStop) } - // if expected > observed, then run the difference - diff := expectedCount - observedCount + // if desired > observed, then run the difference + diff := desiredCount - observedCount var taskDef string // if nothing was running, create new task definition @@ -250,6 +253,7 @@ func (t *Task) stopTasks(ecsTasks []*ecs.Task) error { } // runTasks issues run task request to ECS Service in chunks of count=10 +// it always takes into account the latest ECS params func (t *Task) runTasks(taskDefinition string, totalCount int) ([]*ecs.Task, error) { result := []*ecs.Task{} chunkSize := 10 // can issue only up to 10 tasks in a RunTask Call @@ -302,6 +306,7 @@ func convertToECSTaskOverride(overrides map[string][]string) (*ecs.TaskOverride, return ecsOverrides, nil } +// buildRunTaskInput will account for what is currently specified in ECS Params func (t *Task) buildRunTaskInput(taskDefinition string, count int, overrides map[string][]string) (*ecs.RunTaskInput, error) { cluster := t.Context().CommandConfig.Cluster launchType := t.Context().CommandConfig.LaunchType @@ -314,6 +319,17 @@ func (t *Task) buildRunTaskInput(taskDefinition string, count int, overrides map return nil, err } + placementConstraints, err := composeutils.ConvertToECSPlacementConstraints(ecsParams) + if err != nil { + return nil, err + } + + placementStrategy, err := composeutils.ConvertToECSPlacementStrategy(ecsParams) + if err != nil { + return nil, err + } + + // NOTE: this validation is not useful if called after RegisterTaskDefinition if err := entity.ValidateFargateParams(ecsParams, launchType); err != nil { return nil, err } @@ -338,6 +354,14 @@ func (t *Task) buildRunTaskInput(taskDefinition string, count int, overrides map runTaskInput.Overrides = taskOverride } + if placementConstraints != nil { + runTaskInput.PlacementConstraints = placementConstraints + } + + if placementStrategy != nil { + runTaskInput.PlacementStrategy = placementStrategy + } + if launchType != "" { runTaskInput.LaunchType = aws.String(launchType) } @@ -354,10 +378,11 @@ func (t *Task) createOne() error { return t.waitForRunTasks(ecsTask) } -// up gets a list of running tasks and if updateTasks is set to true, it updates it with the latest task definition -// if count of running tasks = 0, starts 1 -// if count != 0, and the task definitions differed, then its stops the old ones and starts the new ones -func (t *Task) up(updateTasks bool) error { +// up gets a list of running tasks. If there are no running tasks, it starts 1 task. +// If there are no running tasks, and either the task definition has changed or +// forceUpdate is specified, then the running tasks are stopped and relaunched +// with the task definition and run parameters in the current call. +func (t *Task) up(forceUpdate bool) error { ecsTasks, err := entity.CollectTasksWithStatus(t, ecs.DesiredStatusRunning, true) if err != nil { return err @@ -388,7 +413,7 @@ func (t *Task) up(updateTasks bool) error { ecsTaskArns := make(map[string]bool) - if oldTaskDef != newTaskDef { + if oldTaskDef != newTaskDef || forceUpdate { log.WithFields(log.Fields{"taskDefinition": newTaskDef}).Info("Updating to new task definition") chunkSize := 10 diff --git a/ecs-cli/modules/clients/aws/ecs/client.go b/ecs-cli/modules/clients/aws/ecs/client.go index e4c0fa9ab..664fc30f8 100644 --- a/ecs-cli/modules/clients/aws/ecs/client.go +++ b/ecs-cli/modules/clients/aws/ecs/client.go @@ -224,8 +224,9 @@ func (c *ecsClient) RegisterTaskDefinition(request *ecs.RegisterTaskDefinitionIn func (c *ecsClient) RegisterTaskDefinitionIfNeeded( request *ecs.RegisterTaskDefinitionInput, taskDefinitionCache cache.Cache) (*ecs.TaskDefinition, error) { + if request.Family == nil { - return nil, errors.New("invalid task definitions: family is required") + return nil, errors.New("invalid task definition: family is required") } taskDefResp, err := c.DescribeTaskDefinition(aws.StringValue(request.Family)) diff --git a/ecs-cli/modules/clients/aws/ecs/client_test.go b/ecs-cli/modules/clients/aws/ecs/client_test.go index 61842d5f9..e131cfc44 100644 --- a/ecs-cli/modules/clients/aws/ecs/client_test.go +++ b/ecs-cli/modules/clients/aws/ecs/client_test.go @@ -607,6 +607,54 @@ func TestRunTask_WithTaskNetworking(t *testing.T) { assert.NoError(t, err, "Unexpected error when calling RunTask") } +func TestRunTask_WithTaskPlacement(t *testing.T) { + mockEcs, _, client, ctrl := setupTestController(t, getDefaultCLIConfigParams(t)) + defer ctrl.Finish() + + td := "taskDef" + group := "taskGroup" + count := 5 + + placementConstraints := []*ecs.PlacementConstraint{ + { + Type: aws.String("distinctInstance"), + }, { + Expression: aws.String("attribute:ecs.instance-type =~ t2.*"), + Type: aws.String("memberOf"), + }, + } + placementStrategy := []*ecs.PlacementStrategy{ + { + Type: aws.String("random"), + }, { + Field: aws.String("instanceId"), + Type: aws.String("binpack"), + }, + } + + mockEcs.EXPECT().RunTask(gomock.Any()).Do(func(input interface{}) { + req := input.(*ecs.RunTaskInput) + assert.Equal(t, clusterName, aws.StringValue(req.Cluster), "Expected clusterName to match") + assert.Equal(t, td, aws.StringValue(req.TaskDefinition), "Expected taskDefinition to match") + assert.Equal(t, group, aws.StringValue(req.Group), "Expected group to match") + assert.Equal(t, int64(count), aws.Int64Value(req.Count), "Expected count to match") + assert.Equal(t, placementConstraints, req.PlacementConstraints, "Expected placement constraints to match") + assert.Equal(t, placementStrategy, req.PlacementStrategy, "Expected placement strategy to match") + }).Return(&ecs.RunTaskOutput{}, nil) + + runTaskInput := &ecs.RunTaskInput{ + Cluster: aws.String(clusterName), + TaskDefinition: aws.String(td), + Group: aws.String(group), + Count: aws.Int64(int64(count)), + LaunchType: aws.String("EC2"), + PlacementConstraints: placementConstraints, + PlacementStrategy: placementStrategy, + } + _, err := client.RunTask(runTaskInput) + assert.NoError(t, err, "Unexpected error when calling RunTask") +} + func TestIsActiveCluster(t *testing.T) { mockEcs, _, client, ctrl := setupTestController(t, getDefaultCLIConfigParams(t)) defer ctrl.Finish() diff --git a/ecs-cli/modules/commands/compose/compose_command.go b/ecs-cli/modules/commands/compose/compose_command.go index 625c5df0c..b3a8e02d9 100644 --- a/ecs-cli/modules/commands/compose/compose_command.go +++ b/ecs-cli/modules/commands/compose/compose_command.go @@ -132,7 +132,7 @@ func upCommand(factory composeFactory.ProjectFactory) cli.Command { Name: "up", Usage: "Creates an ECS task definition from your compose file (if it does not already exist) and runs one instance of that task on your cluster (a combination of create and start).", Action: compose.WithProject(factory, compose.ProjectUp, false), - Flags: append(flags.OptionalConfigFlags(), flags.OptionalLaunchTypeFlag(), flags.OptionalCreateLogsFlag()), + Flags: append(flags.OptionalConfigFlags(), flags.OptionalLaunchTypeFlag(), flags.OptionalCreateLogsFlag(), flags.OptionalForceUpdateFlag()), OnUsageError: flags.UsageErrorFactory("up"), } } diff --git a/ecs-cli/modules/commands/flags/flags.go b/ecs-cli/modules/commands/flags/flags.go index 2adda0ec1..952f0b222 100644 --- a/ecs-cli/modules/commands/flags/flags.go +++ b/ecs-cli/modules/commands/flags/flags.go @@ -94,6 +94,7 @@ const ( ComposeFileNameFlag = "file" TaskRoleArnFlag = "task-role-arn" ECSParamsFileNameFlag = "ecs-params" + ForceUpdateFlag = "force-update" // Compose Service CreateServiceCommandName = "create" @@ -182,6 +183,14 @@ func OptionalCreateLogsFlag() cli.Flag { } } +// OptionalForceUpdateFlag allows users to force an update of running tasks on compose up. +func OptionalForceUpdateFlag() cli.Flag { + return cli.BoolFlag{ + Name: ForceUpdateFlag + ",u", + Usage: "[Optional] Forces update of task or service with current run parameters", + } +} + // UsageErrorFactory Returns a usage error function for the specified command func UsageErrorFactory(command string) func(*cli.Context, error, bool) error { return func(c *cli.Context, err error, isSubcommand bool) error { diff --git a/ecs-cli/modules/utils/compose/ecs_params_reader.go b/ecs-cli/modules/utils/compose/ecs_params_reader.go index f9c6f86ad..599559910 100644 --- a/ecs-cli/modules/utils/compose/ecs_params_reader.go +++ b/ecs-cli/modules/utils/compose/ecs_params_reader.go @@ -90,6 +90,7 @@ type TaskSize struct { // RunParams specifies non-TaskDefinition specific parameters type RunParams struct { NetworkConfiguration NetworkConfiguration `yaml:"network_configuration"` + TaskPlacement TaskPlacement `yaml:"task_placement"` } // NetworkConfiguration specifies the network config for the task definition. @@ -108,11 +109,27 @@ type AwsVpcConfiguration struct { type AssignPublicIp string +// TODO: Remove; use enum in aws-sdk-go instead (AssignPublicIpEnabled, AssignPublicIpDisabled) const ( Enabled AssignPublicIp = "ENABLED" Disabled AssignPublicIp = "DISABLED" ) +type TaskPlacement struct { + Strategies []Strategy `yaml:"strategy"` + Constraints []Constraint `yaml:"constraints"` +} + +type Strategy struct { + Field string `yaml:"field"` + Type string `yaml:"type"` +} + +type Constraint struct { + Expression string `yaml:"expression"` + Type string `yaml:"type"` +} + ///////////////////////////// ///// Parsing Functions ///// ///////////////////////////// @@ -276,3 +293,48 @@ func parseHealthCheckTime(field string) (*int64, error) { return nil, nil } + +// ConvertToECSPlacementConstraint converts a list of Constraints specified in the +// ecs-params into a format that is compatible with ECSClient calls. +func ConvertToECSPlacementConstraints(ecsParams *ECSParams) ([]*ecs.PlacementConstraint, error) { + if ecsParams == nil { + return nil, nil + } + + constraints := ecsParams.RunParams.TaskPlacement.Constraints + + output := []*ecs.PlacementConstraint{} + for _, constraint := range constraints { + ecsConstraint := &ecs.PlacementConstraint{ + Type: aws.String(constraint.Type), + } + if constraint.Expression != "" { + ecsConstraint.Expression = aws.String(constraint.Expression) + } + output = append(output, ecsConstraint) + } + + return output, nil +} + +// ConvertToECSPlacementStrategy converts a list of Strategies specified in the +// ecs-params into a format that is compatible with ECSClient calls. +func ConvertToECSPlacementStrategy(ecsParams *ECSParams) ([]*ecs.PlacementStrategy, error) { + if ecsParams == nil { + return nil, nil + } + strategies := ecsParams.RunParams.TaskPlacement.Strategies + + output := []*ecs.PlacementStrategy{} + for _, strategy := range strategies { + ecsStrategy := &ecs.PlacementStrategy{ + Type: aws.String(strategy.Type), + } + if strategy.Field != "" { + ecsStrategy.Field = aws.String(strategy.Field) + } + output = append(output, ecsStrategy) + } + + return output, nil +} diff --git a/ecs-cli/modules/utils/compose/ecs_params_reader_test.go b/ecs-cli/modules/utils/compose/ecs_params_reader_test.go index 2b29f7deb..b100efb57 100644 --- a/ecs-cli/modules/utils/compose/ecs_params_reader_test.go +++ b/ecs-cli/modules/utils/compose/ecs_params_reader_test.go @@ -214,6 +214,68 @@ run_params: } } +func TestReadECSParams_WithTaskPlacement(t *testing.T) { + ecsParamsString := `version: 1 +run_params: + task_placement: + strategy: + - field: memory + type: binpack + - field: attribute:ecs.availability-zone + type: spread + constraints: + - expression: attribute:ecs.instance-type =~ t2.* + type: memberOf + - type: distinctInstance` + + content := []byte(ecsParamsString) + + tmpfile, err := ioutil.TempFile("", "ecs-params") + assert.NoError(t, err, "Could not create ecs fields tempfile") + + ecsParamsFileName := tmpfile.Name() + defer os.Remove(ecsParamsFileName) + + _, err = tmpfile.Write(content) + assert.NoError(t, err, "Could not write data to ecs fields tempfile") + + err = tmpfile.Close() + assert.NoError(t, err, "Could not close tempfile") + + expectedStrategies := []Strategy{ + { + Field: "memory", + Type: ecs.PlacementStrategyTypeBinpack, + }, + { + Field: "attribute:ecs.availability-zone", + Type: ecs.PlacementStrategyTypeSpread, + }, + } + + expectedConstraints := []Constraint{ + { + Expression: "attribute:ecs.instance-type =~ t2.*", + Type: ecs.PlacementConstraintTypeMemberOf, + }, + { + Type: ecs.PlacementConstraintTypeDistinctInstance, + }, + } + + ecsParams, err := ReadECSParams(ecsParamsFileName) + + if assert.NoError(t, err) { + taskPlacement := ecsParams.RunParams.TaskPlacement + strategies := taskPlacement.Strategies + constraints := taskPlacement.Constraints + assert.Len(t, strategies, 2) + assert.Len(t, constraints, 2) + assert.ElementsMatch(t, expectedStrategies, strategies) + assert.ElementsMatch(t, expectedConstraints, constraints) + } +} + func TestReadECSParams_MemoryWithUnits(t *testing.T) { ecsParamsString := `version: 1 task_definition: @@ -409,6 +471,86 @@ func TestConvertToECSNetworkConfiguration_NoNetworkConfig(t *testing.T) { } } +func TestConvertToECSPlacementConstraints(t *testing.T) { + constraint1 := Constraint{ + Expression: "attribute:ecs.instance-type =~ t2.*", + Type: ecs.PlacementConstraintTypeMemberOf, + } + constraint2 := Constraint{ + Type: ecs.PlacementConstraintTypeDistinctInstance, + } + constraints := []Constraint{constraint1, constraint2} + taskPlacement := TaskPlacement{ + Constraints: constraints, + } + + ecsParams := &ECSParams{ + RunParams: RunParams{ + TaskPlacement: taskPlacement, + }, + } + + expectedConstraints := []*ecs.PlacementConstraint{ + &ecs.PlacementConstraint{ + Expression: aws.String("attribute:ecs.instance-type =~ t2.*"), + Type: aws.String(ecs.PlacementConstraintTypeMemberOf), + }, + &ecs.PlacementConstraint{ + Type: aws.String(ecs.PlacementConstraintTypeDistinctInstance), + }, + } + + ecsPlacementConstraints, err := ConvertToECSPlacementConstraints(ecsParams) + + if assert.NoError(t, err) { + assert.ElementsMatch(t, expectedConstraints, ecsPlacementConstraints, "Expected placement constraints to match") + } +} + +func TestConvertToECSPlacementStrategy(t *testing.T) { + strategy1 := Strategy{ + Field: "instanceId", + Type: ecs.PlacementStrategyTypeBinpack, + } + strategy2 := Strategy{ + Field: "attribute:ecs.availability-zone", + Type: ecs.PlacementStrategyTypeSpread, + } + strategy3 := Strategy{ + Type: ecs.PlacementStrategyTypeRandom, + } + strategy := []Strategy{strategy1, strategy2, strategy3} + taskPlacement := TaskPlacement{ + Strategies: strategy, + } + + ecsParams := &ECSParams{ + RunParams: RunParams{ + TaskPlacement: taskPlacement, + }, + } + + expectedStrategy := []*ecs.PlacementStrategy{ + &ecs.PlacementStrategy{ + Field: aws.String("instanceId"), + Type: aws.String(ecs.PlacementStrategyTypeBinpack), + }, + &ecs.PlacementStrategy{ + Field: aws.String("attribute:ecs.availability-zone"), + Type: aws.String(ecs.PlacementStrategyTypeSpread), + }, + &ecs.PlacementStrategy{ + Type: aws.String(ecs.PlacementStrategyTypeRandom), + }, + } + + ecsPlacementStrategy, err := ConvertToECSPlacementStrategy(ecsParams) + + if assert.NoError(t, err) { + assert.ElementsMatch(t, expectedStrategy, ecsPlacementStrategy, "Expected placement strategy to match") + } +} + func TestReadECSParams_WithHealthCheck(t *testing.T) { ecsParamsString := `version: 1 task_definition: