Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

container-definition module not usable on its own #147

Open
giom-l opened this issue Dec 14, 2023 · 11 comments
Open

container-definition module not usable on its own #147

giom-l opened this issue Dec 14, 2023 · 11 comments

Comments

@giom-l
Copy link

giom-l commented Dec 14, 2023

Description

This is kinda a reopening of #122.
I also hit the same issues and will try to explain as best as I can.

First thing first, the module is working perfectly fine.
For example (a very simplified example)

variable "subnet_ids" {
  type = list(string)
}

locals {
  name = "test-ecs-module"
  tags = {
    Env     = "test"
    Project = "ecs-module"
  }
}

module "cluster" {
  source = "terraform-aws-modules/ecs/aws//modules/cluster"

  cluster_name = local.name

  fargate_capacity_providers = {
    FARGATE = {
      default_capacity_provider_strategy = {
        weight = 100
      }
    }
  }
  tags = local.tags
}


module "nginx" {
  source                   = "terraform-aws-modules/ecs/aws//modules/container-definition"
  version                  = "5.7.3"
  name                     = local.name
  service                  = local.name
  essential                = true
  readonly_root_filesystem = false
  image                    = "public.ecr.aws/nginx/nginx:1.25.3"
  mount_points = [
    {
      containerPath = "/conf/"
      sourceVolume  = "conf"
      readOnly      = true
    }
  ]
  port_mappings = [
    {
      containerPort = 80
      hostPort      = 80
      protocol      = "tcp"
    }
  ]
  enable_cloudwatch_logging = true
  create_cloudwatch_log_group = false
}

module "service" {
  source      = "terraform-aws-modules/ecs/aws//modules/service"
  version     = "5.7.3"
  name        = local.name
  cluster_arn = module.cluster.arn

  cpu           = 256
  memory        = 512
  desired_count = 1
  launch_type   = "FARGATE"

  create_task_exec_iam_role = true
  create_tasks_iam_role     = true

  create_security_group = true
  security_group_rules = [
    {
      description = "Allow egress"
      type        = "egress"
      protocol    = "all"
      from_port   = 0
      to_port     = 65535
      cidr_blocks = ["0.0.0.0/0"]
    }
  ]
  subnet_ids       = var.subnet_ids
  network_mode     = "awsvpc"
  assign_public_ip = false

  container_definitions = {
    (local.name) = {
     essential                = true
     readonly_root_filesystem = false
     image                    = "public.ecr.aws/nginx/nginx:1.25.3"
     mount_points = [
       {
         containerPath = "/conf/"
         sourceVolume  = "conf"
         readOnly      = true
       }
    ]
     port_mappings = [
       {
         containerPort = 80
         hostPort      = 80
         protocol      = "tcp"
       }
     ]
     enable_cloudwatch_logging = true
   }
  }

  volume = [
    {
      name : "conf"
    }
  ]

  enable_autoscaling             = false
  ignore_task_definition_changes = false
  tags                           = local.tags
  propagate_tags                 = "TASK_DEFINITION"
}

This works.
However, in some of our services, we have up to 5 containers.
Each one of them may have its own port mappings, own volumes, own commands and so.
Having that within only one resource is extremely hard to read and maintain.

On our current stack, we split each container definition in its own module, and the service only refers to all container definitions.
This way, one can better identify the resource, its properties and so on.

Using the current module, it would give something like this :

variable "subnet_ids" {
  type = list(string)
}

locals {
  name = "test-ecs-module"
  tags = {
    Env     = "test"
    Project = "ecs-module"
  }
}

module "cluster" {
  source = "terraform-aws-modules/ecs/aws//modules/cluster"

  cluster_name = local.name

  fargate_capacity_providers = {
    FARGATE = {
      default_capacity_provider_strategy = {
        weight = 100
      }
    }
  }
  tags = local.tags
}


module "nginx" {
  source                   = "terraform-aws-modules/ecs/aws//modules/container-definition"
  version                  = "5.7.3"
  name                     = local.name
  service                  = local.name
  essential                = true
  readonly_root_filesystem = false
  image                    = "public.ecr.aws/nginx/nginx:1.25.3"
  mount_points = [
    {
      containerPath = "/conf/"
      sourceVolume  = "conf"
      readOnly      = true
    }
  ]
  port_mappings = [
    {
      containerPort = 80
      hostPort      = 80
      protocol      = "tcp"
    }
  ]
  enable_cloudwatch_logging = true
  create_cloudwatch_log_group = false
}

module "service" {
  source      = "terraform-aws-modules/ecs/aws//modules/service"
  version     = "5.7.3"
  name        = local.name
  cluster_arn = module.cluster.arn

  cpu           = 256
  memory        = 512
  desired_count = 1
  launch_type   = "FARGATE"

  create_task_exec_iam_role = true
  create_tasks_iam_role     = true

  create_security_group = true
  security_group_rules = [
    {
      description = "Allow egress"
      type        = "egress"
      protocol    = "all"
      from_port   = 0
      to_port     = 65535
      cidr_blocks = ["0.0.0.0/0"]
    }
  ]
  subnet_ids       = var.subnet_ids
  network_mode     = "awsvpc"
  assign_public_ip = false

  container_definitions = {
    (local.name) = module.nginx.container_definition
  }

  volume = [
    {
      name : "conf"
    }
  ]

  enable_autoscaling             = false
  ignore_task_definition_changes = false
  tags                           = local.tags
  propagate_tags                 = "TASK_DEFINITION"
}

However, this does not work because of the case used in container-definition outputs for fields composed by multiple words.
Hence, instead of having portMappings, it should return the proper property name port_mappings

I made it work by modifying the local module
from

locals {
  is_not_windows = contains(["LINUX"], var.operating_system_family)

  log_group_name = "/aws/ecs/${var.service}/${var.name}"

  log_configuration = merge(
    { for k, v in {
      logDriver = "awslogs",
      options = {
        awslogs-region        = data.aws_region.current.name,
        awslogs-group         = try(aws_cloudwatch_log_group.this[0].name, ""),
        awslogs-stream-prefix = "ecs"
      },
    } : k => v if var.enable_cloudwatch_logging },
    var.log_configuration
  )

  linux_parameters = var.enable_execute_command ? merge({ "initProcessEnabled" : true }, var.linux_parameters) : merge({ "initProcessEnabled" : false }, var.linux_parameters)

  definition = {
    command                = length(var.command) > 0 ? var.command : null
    cpu                    = var.cpu
    dependsOn              = length(var.dependencies) > 0 ? var.dependencies : null # depends_on is a reserved word
    disableNetworking      = local.is_not_windows ? var.disable_networking : null
    dnsSearchDomains       = local.is_not_windows && length(var.dns_search_domains) > 0 ? var.dns_search_domains : null
    dnsServers             = local.is_not_windows && length(var.dns_servers) > 0 ? var.dns_servers : null
    dockerLabels           = length(var.docker_labels) > 0 ? var.docker_labels : null
    dockerSecurityOptions  = length(var.docker_security_options) > 0 ? var.docker_security_options : null
    entrypoint             = length(var.entrypoint) > 0 ? var.entrypoint : null
    environment            = var.environment
    environmentFiles       = length(var.environment_files) > 0 ? var.environment_files : null
    essential              = var.essential
    extraHosts             = local.is_not_windows && length(var.extra_hosts) > 0 ? var.extra_hosts : null
    firelensConfiguration  = length(var.firelens_configuration) > 0 ? var.firelens_configuration : null
    healthCheck            = length(var.health_check) > 0 ? var.health_check : null
    hostname               = var.hostname
    image                  = var.image
    interactive            = var.interactive
    links                  = local.is_not_windows && length(var.links) > 0 ? var.links : null
    linuxParameters        = local.is_not_windows && length(local.linux_parameters) > 0 ? local.linux_parameters : null
    logConfiguration       = length(local.log_configuration) > 0 ? local.log_configuration : null
    memory                 = var.memory
    memoryReservation      = var.memory_reservation
    mountPoints            = var.mount_points
    name                   = var.name
    portMappings           = var.port_mappings
    privileged             = local.is_not_windows ? var.privileged : null
    pseudoTerminal         = var.pseudo_terminal
    readonlyRootFilesystem = local.is_not_windows ? var.readonly_root_filesystem : null
    repositoryCredentials  = length(var.repository_credentials) > 0 ? var.repository_credentials : null
    resourceRequirements   = length(var.resource_requirements) > 0 ? var.resource_requirements : null
    secrets                = length(var.secrets) > 0 ? var.secrets : null
    startTimeout           = var.start_timeout
    stopTimeout            = var.stop_timeout
    systemControls         = length(var.system_controls) > 0 ? var.system_controls : null
    ulimits                = local.is_not_windows && length(var.ulimits) > 0 ? var.ulimits : null
    user                   = local.is_not_windows ? var.user : null
    volumesFrom            = var.volumes_from
    workingDirectory       = var.working_directory
  }

  # Strip out all null values, ECS API will provide defaults in place of null/empty values
  container_definition = { for k, v in local.definition : k => v if v != null }
}

to

locals {
  is_not_windows = contains(["LINUX"], var.operating_system_family)

  log_group_name = "/aws/ecs/${var.service}/${var.name}"

  log_configuration = merge(
    { for k, v in {
      logDriver = "awslogs",
      options = {
        awslogs-region        = data.aws_region.current.name,
        awslogs-group         = try(aws_cloudwatch_log_group.this[0].name, ""),
        awslogs-stream-prefix = "ecs"
      },
    } : k => v if var.enable_cloudwatch_logging },
    var.log_configuration
  )

  linux_parameters = var.enable_execute_command ? merge({ "initProcessEnabled" : true }, var.linux_parameters) : merge({ "initProcessEnabled" : false }, var.linux_parameters)

  definition = {
    command                = length(var.command) > 0 ? var.command : null
    cpu                    = var.cpu
    depends_on              = length(var.dependencies) > 0 ? var.dependencies : null # depends_on is a reserved word
    disable_networking      = local.is_not_windows ? var.disable_networking : null
    dns_search_domains       = local.is_not_windows && length(var.dns_search_domains) > 0 ? var.dns_search_domains : null
    dns_servers             = local.is_not_windows && length(var.dns_servers) > 0 ? var.dns_servers : null
    docker_labels           = length(var.docker_labels) > 0 ? var.docker_labels : null
    docker_security_options  = length(var.docker_security_options) > 0 ? var.docker_security_options : null
    entrypoint             = length(var.entrypoint) > 0 ? var.entrypoint : null
    environment            = var.environment
    environment_diles       = length(var.environment_files) > 0 ? var.environment_files : null
    essential              = var.essential
    extra_hosts             = local.is_not_windows && length(var.extra_hosts) > 0 ? var.extra_hosts : null
    firelens_configuration  = length(var.firelens_configuration) > 0 ? var.firelens_configuration : null
    health_check            = length(var.health_check) > 0 ? var.health_check : null
    hostname               = var.hostname
    image                  = var.image
    interactive            = var.interactive
    links                  = local.is_not_windows && length(var.links) > 0 ? var.links : null
    linux_parameters        = local.is_not_windows && length(local.linux_parameters) > 0 ? local.linux_parameters : null
    log_configuration       = length(local.log_configuration) > 0 ? local.log_configuration : null
    memory                 = var.memory
    memory_reservation      = var.memory_reservation
    mount_points            = var.mount_points
    name                   = var.name
    port_mappings           = var.port_mappings
    privileged             = local.is_not_windows ? var.privileged : null
    pseudo_terminal         = var.pseudo_terminal
    readonly_root_filesystem = local.is_not_windows ? var.readonly_root_filesystem : null
    repository_credentials  = length(var.repository_credentials) > 0 ? var.repository_credentials : null
    resource_requirements   = length(var.resource_requirements) > 0 ? var.resource_requirements : null
    secrets                = length(var.secrets) > 0 ? var.secrets : null
    start_timeout           = var.start_timeout
    stop_timeout            = var.stop_timeout
    system_controls         = length(var.system_controls) > 0 ? var.system_controls : null
    ulimits                = local.is_not_windows && length(var.ulimits) > 0 ? var.ulimits : null
    user                   = local.is_not_windows ? var.user : null
    volumes_from            = var.volumes_from
    working_directory       = var.working_directory
  }

  # Strip out all null values, ECS API will provide defaults in place of null/empty values
  container_definition = { for k, v in local.definition : k => v if v != null }
}

Versions

  • Module version [Required]: 5.7.3
  • Terraform version: v1.6.5
  • Provider version(s): provider registry.terraform.io/hashicorp/aws v5.30.0

Reproduction Code [Required]

Provided above.

Steps to reproduce the behavior:
Just run
terraform init && terraform apply -subnet_ids='[<your own list>]'

Expected behavior

It would be better for the container-definition module to be usable on its own.

Actual behavior

We can only use the service module with all container definition inside it.
No split available.

@bryantbiggs
Copy link
Member

Hence, instead of having portMappings, it should return the proper property name port_mappings

No, I beg to disagree. The container definition sub-module should render a JSON document that meets the container definition API https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_ContainerDefinition.html

The API expects portMappings not port_mappings - the snake case is a Terraform convention, but it will fail if you try to pass this to the container definition API. You can even see this reflected in the examples on the AWS provider https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_task_definition#basic-example

@giom-l
Copy link
Author

giom-l commented Dec 14, 2023

I thought about something like this but haven't the possibility to test it right now.

However, would it be possible to make the output compatible, without touching the module itself ?

This would be the best of both world (unless I miss something else)

@bryantbiggs
Copy link
Member

I'd like to know more about whats working on not working. we tend to jump into solutioning which muddies the water a bit.

Are you stating that this does not work?

variable "subnet_ids" {
  type = list(string)
}

locals {
  name = "test-ecs-module"
  tags = {
    Env     = "test"
    Project = "ecs-module"
  }
}

module "cluster" {
  source = "terraform-aws-modules/ecs/aws//modules/cluster"

  cluster_name = local.name

  fargate_capacity_providers = {
    FARGATE = {
      default_capacity_provider_strategy = {
        weight = 100
      }
    }
  }
  tags = local.tags
}


module "nginx" {
  source                   = "terraform-aws-modules/ecs/aws//modules/container-definition"
  version                  = "5.7.3"
  name                     = local.name
  service                  = local.name
  essential                = true
  readonly_root_filesystem = false
  image                    = "public.ecr.aws/nginx/nginx:1.25.3"
  mount_points = [
    {
      containerPath = "/conf/"
      sourceVolume  = "conf"
      readOnly      = true
    }
  ]
  port_mappings = [
    {
      containerPort = 80
      hostPort      = 80
      protocol      = "tcp"
    }
  ]
  enable_cloudwatch_logging = true
  create_cloudwatch_log_group = false
}

module "service" {
  source      = "terraform-aws-modules/ecs/aws//modules/service"
  version     = "5.7.3"
  name        = local.name
  cluster_arn = module.cluster.arn

  cpu           = 256
  memory        = 512
  desired_count = 1
  launch_type   = "FARGATE"

  create_task_exec_iam_role = true
  create_tasks_iam_role     = true

  create_security_group = true
  security_group_rules = [
    {
      description = "Allow egress"
      type        = "egress"
      protocol    = "all"
      from_port   = 0
      to_port     = 65535
      cidr_blocks = ["0.0.0.0/0"]
    }
  ]
  subnet_ids       = var.subnet_ids
  network_mode     = "awsvpc"
  assign_public_ip = false

  container_definitions = {
    (local.name) = jsonencode(module.nginx.container_definition)
  }

  volume = [
    {
      name : "conf"
    }
  ]

  enable_autoscaling             = false
  ignore_task_definition_changes = false
  tags                           = local.tags
  propagate_tags                 = "TASK_DEFINITION"
}

@giom-l
Copy link
Author

giom-l commented Dec 14, 2023

Yes, exactly.
In fact, it is working in terraform sense, since we have no errors on apply.
However, the task definition is not complete

In the plan, we can see that some fields are missing (all that are composed by multiple words)
see mountPoints, portMappings

# module.service.aws_ecs_task_definition.this[0] will be created
  + resource "aws_ecs_task_definition" "this" {
      + arn                      = (known after apply)
      + arn_without_revision     = (known after apply)
      + container_definitions    = jsonencode(
            [
              + {
                  + environment            = []
                  + essential              = true
                  + image                  = "public.ecr.aws/nginx/nginx:1.25.3"
                  + interactive            = false
                  + linuxParameters        = {
                      + initProcessEnabled = false
                    }
                  + logConfiguration       = {
                      + logDriver = "awslogs"
                      + options   = {
                          + awslogs-group         = "/aws/ecs/test-ecs-module/test-ecs-module"
                          + awslogs-region        = "eu-west-1"
                          + awslogs-stream-prefix = "ecs"
                        }
                    }
                  + mountPoints            = []
                  + name                   = "test-ecs-module"
                  + portMappings           = []
                  + privileged             = false
                  + pseudoTerminal         = false
                  + readonlyRootFilesystem = true
                  + startTimeout           = 30
                  + stopTimeout            = 120
                  + user                   = "0"
                  + volumesFrom            = []
                },
            ]
        )
      + cpu                      = "256"
      + execution_role_arn       = (known after apply)
      + family                   = "test-ecs-module"
      + id                       = (known after apply)
      + memory                   = "512"
      + network_mode             = "awsvpc"
      + requires_compatibilities = [
          + "FARGATE",
        ]
      + revision                 = (known after apply)
      + skip_destroy             = false
      + tags                     = {
          + "Env"     = "test"
          + "Project" = "ecs-module"
        }
      + tags_all                 = {
          + "Env"     = "test"
          + "Project" = "ecs-module"
        }
      + task_role_arn            = (known after apply)

      + runtime_platform {
          + cpu_architecture        = "X86_64"
          + operating_system_family = "LINUX"
        }

      + volume {
          + name = "conf"
        }
    }

So we have no errors, but the service may not be functioning normally.

@bryantbiggs
Copy link
Member

When I use this:

provider "aws" {
  region = "us-east-1"
}

locals {
  name = "nginx"
}

module "nginx" {
  source  = "terraform-aws-modules/ecs/aws//modules/container-definition"
  version = "~> 5.0"

  name                     = local.name
  service                  = local.name
  essential                = true
  readonly_root_filesystem = false
  image                    = "public.ecr.aws/nginx/nginx:1.25.3"
  mount_points = [
    {
      containerPath = "/conf/"
      sourceVolume  = "conf"
      readOnly      = true
    }
  ]
  port_mappings = [
    {
      containerPort = 80
      hostPort      = 80
      protocol      = "tcp"
    }
  ]
  enable_cloudwatch_logging   = true
  create_cloudwatch_log_group = false
}

resource "local_file" "out" {
  content  = jsonencode(module.nginx.container_definition)
  filename = "${path.module}/container_definition.json"
}

I get a container definition that has all of the expected values based on the inputs:

{
	"environment": [],
	"essential": true,
	"image": "public.ecr.aws/nginx/nginx:1.25.3",
	"interactive": false,
	"linuxParameters": {
		"initProcessEnabled": false
	},
	"logConfiguration": {
		"logDriver": "awslogs",
		"options": {
			"awslogs-group": "",
			"awslogs-region": "us-east-1",
			"awslogs-stream-prefix": "ecs"
		}
	},
	"mountPoints": [
		{
			"containerPath": "/conf/",
			"readOnly": true,
			"sourceVolume": "conf"
		}
	],
	"name": "nginx",
	"portMappings": [
		{
			"containerPort": 80,
			"hostPort": 80,
			"protocol": "tcp"
		}
	],
	"privileged": false,
	"pseudoTerminal": false,
	"readonlyRootFilesystem": false,
	"startTimeout": 30,
	"stopTimeout": 120,
	"volumesFrom": []
}

@giom-l
Copy link
Author

giom-l commented Dec 14, 2023

I do agree and am able to reproduce.
But in this file, we have eg mountPoints where in the service module, it looks for mount_points

mount_points             = try(each.value.mount_points, var.container_definition_defaults.mount_points, [])

And the same goes for portMappings

@bryantbiggs
Copy link
Member

ahhhhhhh ok - now I got ya. thank you!

@giom-l
Copy link
Author

giom-l commented Dec 15, 2023

You're welcome.
If I can be of only help (for PR or anything else) please let me know (I just don't see at the moment what would be the best way to make it work without breaking everything)

Copy link

This issue has been automatically marked as stale because it has been open 30 days
with no activity. Remove stale label or comment or this issue will be closed in 10 days

@liamraeAL
Copy link

liamraeAL commented Jan 25, 2024

Yes it appears the output of the container_definiton sub-module container_definition outputs in a different format than what the service submodule container_definiton expects.

container_definition output has a camel casing of portMapping whereas the service is looking for snake_case port_mappings

container_definiton submodule - portMappings = var.port_mappings

service submodule - port_mappings = try(each.value.port_mappings, var.container_definition_defaults.port_mappings, [])

This means this trying to use the output module.container_definition.container_definition inside the service module results in empty [] for portMappings (and any other variable that is camel case)

@blueelvis
Copy link

This is also happening with environment_files. So I am assuming that all the snakeCasing ones are not working because of this same issue, right?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants