diff --git a/infrastructure/modules/ecs/.terraform.lock.hcl b/infrastructure/modules/ecs/.terraform.lock.hcl new file mode 100644 index 0000000000..55057fd2e7 --- /dev/null +++ b/infrastructure/modules/ecs/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.22.0" + constraints = "6.22.0" + hashes = [ + "h1:TV1UZ7DzioV1EUY/lMS+eIInU379DA1Q2QwnEGGZMks=", + "zh:0ed7ceb13bade9076021a14f995d07346d3063f4a419a904d5804d76e372bbda", + "zh:195dcde5a4b0def82bc3379053edc13941ff94ea5905808fe575f7c7bbd66693", + "zh:4047c4dba121d29859b72d2155c47f969b41d3c5768f73dff5d8a0cc55f74e52", + "zh:5694f37d6ea69b6f96dfb30d53e66f7a41c1aad214c212b6ffa54bdd799e3b27", + "zh:6cf8bb7d984b1fae9fd10d6ce1e62f6c10751a1040734b75a1f7286609782e49", + "zh:737d0e600dfe2626b4d6fc5dd2b24c0997fd983228a7a607b9176a1894a281a0", + "zh:7d328a195ce36b1170afe6758cf88223c8765620211f5cc0451bdd6899243b4e", + "zh:7edb4bc34baeba92889bd9ed50b34c04b3eeb3d8faa8bb72699c6335a2e95bab", + "zh:8e71836814e95454b00c51f3cb3e10fd78a59f7dc4c5362af64233fee989790d", + "zh:9367f63b23d9ddfab590b2247a8ff5ccf83410cbeca43c6e441c488c45efff4c", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a007de80ffde8539a73ee39fcfbe7ed12e025c98cd29b2110a7383b41a4aad39", + "zh:aae7b7aed8bf3a4bea80a9a2f08fef1adeb748beff236c4a54af93bb6c09a56c", + "zh:b5a16b59d4210c1eaf35c8c027ecdab9e074dd081d602f5112eecdebf2e1866d", + "zh:d479bad0a004e4893bf0ba6c6cd867fefd14000051bbe3de5b44a925e3d46cd5", + ] +} diff --git a/infrastructure/modules/ecs/main.tf b/infrastructure/modules/ecs/main.tf index 9eacb65553..2e32045b4b 100644 --- a/infrastructure/modules/ecs/main.tf +++ b/infrastructure/modules/ecs/main.tf @@ -27,40 +27,41 @@ resource "aws_ecs_cluster_capacity_providers" "main" { } } + +# TODO: disallow tag mutability +# NOSEMGREP: terraform.aws.security.aws-ecr-mutable-image-tags.aws-ecr-mutable-image-tags +resource "aws_ecr_repository" "main" { + image_tag_mutability = "MUTABLE" + name = "${var.project_name}-${var.environment}-backend" + tags = var.common_tags + + image_scanning_configuration { + scan_on_push = true + } +} + resource "aws_ecr_lifecycle_policy" "main" { repository = aws_ecr_repository.main.name policy = jsonencode({ rules = [ { - rulePriority = 1 + action = { + type = "expire" + } description = "Remove untagged images" + rulePriority = 1 selection = { - tagStatus = "untagged" + countNumber = 7 countType = "sinceImagePushed" countUnit = "days" - countNumber = 7 - } - action = { - type = "expire" + tagStatus = "untagged" } } ] }) } -# TODO: disallow tag mutability -# nosemgrep: terraform.aws.security.aws-ecr-mutable-image-tags.aws-ecr-mutable-image-tags -resource "aws_ecr_repository" "main" { - name = "${var.project_name}-${var.environment}-backend" - image_tag_mutability = "MUTABLE" - tags = var.common_tags - - image_scanning_configuration { - scan_on_push = true - } -} - resource "aws_iam_role" "ecs_tasks_execution_role" { name = "${var.project_name}-${var.environment}-ecs-tasks-execution-role" tags = var.common_tags @@ -81,8 +82,8 @@ resource "aws_iam_role" "ecs_tasks_execution_role" { resource "aws_iam_policy" "ecs_tasks_execution_role_ssm_policy" { - name = "${var.project_name}-${var.environment}-ecs-tasks-ssm-policy" description = "Allow ECS tasks to read SSM parameters" + name = "${var.project_name}-${var.environment}-ecs-tasks-ssm-policy" policy = jsonencode({ Version = "2012-10-17" Statement = [ @@ -99,8 +100,8 @@ resource "aws_iam_policy" "ecs_tasks_execution_role_ssm_policy" { } resource "aws_iam_policy" "ecs_tasks_execution_policy" { - name = "${var.project_name}-${var.environment}-ecs-tasks-execution-policy" description = "Custom policy for ECS task execution - ECR and CloudWatch Logs access" + name = "${var.project_name}-${var.environment}-ecs-tasks-execution-policy" policy = jsonencode({ Version = "2012-10-17" @@ -147,9 +148,6 @@ resource "aws_iam_role_policy_attachment" "ecs_tasks_execution_role_ssm_policy_a } resource "aws_iam_role" "ecs_task_role" { - name = "${var.project_name}-${var.environment}-ecs-task-role" - tags = var.common_tags - assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [ @@ -162,6 +160,8 @@ resource "aws_iam_role" "ecs_task_role" { } ] }) + name = "${var.project_name}-${var.environment}-ecs-task-role" + tags = var.common_tags } resource "aws_iam_role_policy_attachment" "ecs_task_role_fixtures_s3_access" { @@ -170,9 +170,6 @@ resource "aws_iam_role_policy_attachment" "ecs_task_role_fixtures_s3_access" { } resource "aws_iam_role" "event_bridge_role" { - name = "${var.project_name}-${var.environment}-event-bridge-role" - tags = var.common_tags - assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [ @@ -185,12 +182,13 @@ resource "aws_iam_role" "event_bridge_role" { } ] }) + name = "${var.project_name}-${var.environment}-event-bridge-role" + tags = var.common_tags } resource "aws_iam_policy" "event_bridge_ecs_policy" { - name = "${var.project_name}-${var.environment}-event-bridge-ecs-policy" description = "Allow EventBridge to run ECS tasks" - + name = "${var.project_name}-${var.environment}-event-bridge-ecs-policy" policy = jsonencode({ Version = "2012-10-17" Statement = [ @@ -206,7 +204,7 @@ resource "aws_iam_policy" "event_bridge_ecs_policy" { }, { # https://docs.aws.amazon.com/AmazonECS/latest/developerguide/CWE_IAM_role.html - # nosemgrep: terraform.lang.security.iam.no-iam-resource-exposure.no-iam-resource-exposure + # NOSEMGREP: terraform.lang.security.iam.no-iam-resource-exposure.no-iam-resource-exposure Action = "iam:PassRole" Effect = "Allow" Resource = [ diff --git a/infrastructure/modules/ecs/modules/task/.terraform.lock.hcl b/infrastructure/modules/ecs/modules/task/.terraform.lock.hcl new file mode 100644 index 0000000000..55057fd2e7 --- /dev/null +++ b/infrastructure/modules/ecs/modules/task/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.22.0" + constraints = "6.22.0" + hashes = [ + "h1:TV1UZ7DzioV1EUY/lMS+eIInU379DA1Q2QwnEGGZMks=", + "zh:0ed7ceb13bade9076021a14f995d07346d3063f4a419a904d5804d76e372bbda", + "zh:195dcde5a4b0def82bc3379053edc13941ff94ea5905808fe575f7c7bbd66693", + "zh:4047c4dba121d29859b72d2155c47f969b41d3c5768f73dff5d8a0cc55f74e52", + "zh:5694f37d6ea69b6f96dfb30d53e66f7a41c1aad214c212b6ffa54bdd799e3b27", + "zh:6cf8bb7d984b1fae9fd10d6ce1e62f6c10751a1040734b75a1f7286609782e49", + "zh:737d0e600dfe2626b4d6fc5dd2b24c0997fd983228a7a607b9176a1894a281a0", + "zh:7d328a195ce36b1170afe6758cf88223c8765620211f5cc0451bdd6899243b4e", + "zh:7edb4bc34baeba92889bd9ed50b34c04b3eeb3d8faa8bb72699c6335a2e95bab", + "zh:8e71836814e95454b00c51f3cb3e10fd78a59f7dc4c5362af64233fee989790d", + "zh:9367f63b23d9ddfab590b2247a8ff5ccf83410cbeca43c6e441c488c45efff4c", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a007de80ffde8539a73ee39fcfbe7ed12e025c98cd29b2110a7383b41a4aad39", + "zh:aae7b7aed8bf3a4bea80a9a2f08fef1adeb748beff236c4a54af93bb6c09a56c", + "zh:b5a16b59d4210c1eaf35c8c027ecdab9e074dd081d602f5112eecdebf2e1866d", + "zh:d479bad0a004e4893bf0ba6c6cd867fefd14000051bbe3de5b44a925e3d46cd5", + ] +} diff --git a/infrastructure/modules/ecs/modules/task/main.tf b/infrastructure/modules/ecs/modules/task/main.tf index 2e774835f2..526e8b7944 100644 --- a/infrastructure/modules/ecs/modules/task/main.tf +++ b/infrastructure/modules/ecs/modules/task/main.tf @@ -9,22 +9,13 @@ terraform { } } -resource "aws_cloudwatch_log_group" "task" { - kms_key_id = var.kms_key_arn - name = "/aws/ecs/${var.project_name}-${var.environment}-${var.task_name}" - retention_in_days = var.log_retention_in_days - tags = merge(var.common_tags, { - Name = "${var.project_name}-${var.environment}-${var.task_name}-logs" - }) -} - resource "aws_ecs_task_definition" "task" { + cpu = var.cpu + execution_role_arn = var.ecs_tasks_execution_role_arn family = "${var.project_name}-${var.environment}-${var.task_name}" + memory = var.memory network_mode = "awsvpc" requires_compatibilities = ["FARGATE"] - cpu = var.cpu - memory = var.memory - execution_role_arn = var.ecs_tasks_execution_role_arn task_role_arn = var.task_role_arn tags = merge(var.common_tags, { Name = "${var.project_name}-${var.environment}-${var.task_name}-task-def" @@ -32,10 +23,9 @@ resource "aws_ecs_task_definition" "task" { container_definitions = jsonencode([ { - name = "backend" - image = var.image_url command = var.command essential = true + image = var.image_url logConfiguration = { logDriver = "awslogs" options = { @@ -44,6 +34,7 @@ resource "aws_ecs_task_definition" "task" { "awslogs-stream-prefix" = "ecs" } } + name = "backend" secrets = [for name, valueFrom in var.container_parameters_arns : { name = name valueFrom = valueFrom @@ -52,11 +43,19 @@ resource "aws_ecs_task_definition" "task" { ]) } -resource "aws_cloudwatch_event_rule" "task" { - count = var.schedule_expression != null ? 1 : 0 +resource "aws_cloudwatch_log_group" "task" { + kms_key_id = var.kms_key_arn + name = "/aws/ecs/${var.project_name}-${var.environment}-${var.task_name}" + retention_in_days = var.log_retention_in_days + tags = merge(var.common_tags, { + Name = "${var.project_name}-${var.environment}-${var.task_name}-logs" + }) +} - name = "${var.project_name}-${var.environment}-${var.task_name}-rule" +resource "aws_cloudwatch_event_rule" "task" { + count = var.schedule_expression != null ? 1 : 0 description = "Fires on a schedule to trigger the ${var.task_name} task" + name = "${var.project_name}-${var.environment}-${var.task_name}-rule" schedule_expression = var.schedule_expression tags = merge(var.common_tags, { Name = "${var.project_name}-${var.environment}-${var.task_name}-rule" @@ -64,9 +63,8 @@ resource "aws_cloudwatch_event_rule" "task" { } resource "aws_cloudwatch_event_target" "task" { - count = var.schedule_expression != null ? 1 : 0 - arn = var.ecs_cluster_arn + count = var.schedule_expression != null ? 1 : 0 role_arn = var.event_bridge_role_arn rule = aws_cloudwatch_event_rule.task[0].name target_id = "${var.project_name}-${var.environment}-${var.task_name}-target" diff --git a/infrastructure/modules/ecs/modules/task/tests/task.tftest.hcl b/infrastructure/modules/ecs/modules/task/tests/task.tftest.hcl new file mode 100644 index 0000000000..785a7257cd --- /dev/null +++ b/infrastructure/modules/ecs/modules/task/tests/task.tftest.hcl @@ -0,0 +1,165 @@ +variables { + aws_region = "us-east-2" + command = ["/bin/sh", "-c", "echo test"] + common_tags = { Environment = "test", Project = "nest" } + container_parameters_arns = {} + cpu = "256" + ecs_cluster_arn = "arn:aws:ecs:us-east-2:123456789012:cluster/test-cluster" + ecs_tasks_execution_role_arn = "arn:aws:iam::123456789012:role/test-execution-role" + environment = "test" + image_url = "123456789012.dkr.ecr.us-east-2.amazonaws.com/test:latest" + kms_key_arn = "arn:aws:kms:us-east-2:123456789012:key/12345678-1234-1234-1234-123456789012" + memory = "512" + project_name = "nest" + security_group_ids = ["sg-12345678"] + subnet_ids = ["subnet-12345678"] + task_name = "test-task" +} + +run "test_task_definition_family_format" { + command = plan + + assert { + condition = aws_ecs_task_definition.task.family == "${var.project_name}-${var.environment}-${var.task_name}" + error_message = "Task definition family must follow format: {project}-{environment}-{task_name}." + } +} + +run "test_task_definition_network_mode" { + command = plan + + assert { + condition = aws_ecs_task_definition.task.network_mode == "awsvpc" + error_message = "Task definition must use awsvpc network mode." + } +} + +run "test_task_definition_requires_fargate" { + command = plan + + assert { + condition = contains(aws_ecs_task_definition.task.requires_compatibilities, "FARGATE") + error_message = "Task definition must require FARGATE compatibility." + } +} + +run "test_task_definition_cpu" { + command = plan + + assert { + condition = aws_ecs_task_definition.task.cpu == var.cpu + error_message = "Task definition CPU must match variable." + } +} + +run "test_task_definition_memory" { + command = plan + + assert { + condition = aws_ecs_task_definition.task.memory == var.memory + error_message = "Task definition memory must match variable." + } +} + +run "test_log_group_name_format" { + command = plan + + assert { + condition = aws_cloudwatch_log_group.task.name == "/aws/ecs/${var.project_name}-${var.environment}-${var.task_name}" + error_message = "Log group name must follow format: /aws/ecs/{project}-{environment}-{task_name}." + } +} + +run "test_log_group_retention" { + command = plan + + assert { + condition = aws_cloudwatch_log_group.task.retention_in_days == 90 + error_message = "Log group retention must be 90 days by default." + } +} + +run "test_log_group_encrypted" { + command = plan + + assert { + condition = aws_cloudwatch_log_group.task.kms_key_id == var.kms_key_arn + error_message = "Log group must be encrypted with KMS key." + } +} + +run "test_eventbridge_rule_not_created_without_schedule" { + command = plan + + assert { + condition = length(aws_cloudwatch_event_rule.task) == 0 + error_message = "EventBridge rule should not be created when schedule_expression is null." + } +} + +run "test_eventbridge_target_not_created_without_schedule" { + command = plan + + assert { + condition = length(aws_cloudwatch_event_target.task) == 0 + error_message = "EventBridge target should not be created when schedule_expression is null." + } +} + +run "test_eventbridge_rule_created_with_schedule" { + command = plan + + variables { + schedule_expression = "cron(0 12 * * ? *)" + event_bridge_role_arn = "arn:aws:iam::123456789012:role/test-eventbridge-role" + } + + assert { + condition = length(aws_cloudwatch_event_rule.task) == 1 + error_message = "EventBridge rule should be created when schedule_expression is set." + } +} + +run "test_eventbridge_rule_name_format" { + command = plan + + variables { + schedule_expression = "cron(0 12 * * ? *)" + event_bridge_role_arn = "arn:aws:iam::123456789012:role/test-eventbridge-role" + } + + assert { + condition = aws_cloudwatch_event_rule.task[0].name == "${var.project_name}-${var.environment}-${var.task_name}-rule" + error_message = "EventBridge rule name must follow format: {project}-{environment}-{task_name}-rule." + } +} + +run "test_capacity_provider_fargate_default" { + command = plan + + variables { + schedule_expression = "cron(0 12 * * ? *)" + event_bridge_role_arn = "arn:aws:iam::123456789012:role/test-eventbridge-role" + use_fargate_spot = false + } + + assert { + condition = one(aws_cloudwatch_event_target.task[0].ecs_target[0].capacity_provider_strategy).capacity_provider == "FARGATE" + error_message = "Capacity provider must be FARGATE when use_fargate_spot is false." + } +} + +run "test_capacity_provider_fargate_spot" { + command = plan + + variables { + schedule_expression = "cron(0 12 * * ? *)" + event_bridge_role_arn = "arn:aws:iam::123456789012:role/test-eventbridge-role" + use_fargate_spot = true + } + + assert { + condition = one(aws_cloudwatch_event_target.task[0].ecs_target[0].capacity_provider_strategy).capacity_provider == "FARGATE_SPOT" + error_message = "Capacity provider must be FARGATE_SPOT when use_fargate_spot is true." + } +} diff --git a/infrastructure/modules/ecs/tests/ecs.tftest.hcl b/infrastructure/modules/ecs/tests/ecs.tftest.hcl new file mode 100644 index 0000000000..dfef0d5c11 --- /dev/null +++ b/infrastructure/modules/ecs/tests/ecs.tftest.hcl @@ -0,0 +1,111 @@ +variables { + aws_region = "us-east-2" + common_tags = { Environment = "test", Project = "nest" } + container_parameters_arns = {} + ecs_sg_id = "sg-12345678" + environment = "test" + fixtures_bucket_name = "nest-fixtures-abcd1234" + fixtures_read_only_policy_arn = "arn:aws:iam::123456789012:policy/test-fixtures-policy" + kms_key_arn = "arn:aws:kms:us-east-2:123456789012:key/12345678-1234-1234-1234-123456789012" + project_name = "nest" + subnet_ids = ["subnet-12345678"] +} + +run "test_ecs_cluster_name_format" { + command = plan + + assert { + condition = aws_ecs_cluster.main.name == "${var.project_name}-${var.environment}-tasks-cluster" + error_message = "ECS cluster name must follow format: {project}-{environment}-tasks-cluster." + } +} + +run "test_ecs_capacity_providers" { + command = plan + + assert { + condition = contains(aws_ecs_cluster_capacity_providers.main.capacity_providers, "FARGATE") + error_message = "ECS cluster must have FARGATE capacity provider." + } +} + +run "test_ecs_capacity_providers_spot" { + command = plan + + assert { + condition = contains(aws_ecs_cluster_capacity_providers.main.capacity_providers, "FARGATE_SPOT") + error_message = "ECS cluster must have FARGATE_SPOT capacity provider." + } +} + +run "test_ecr_repository_name_format" { + command = plan + + assert { + condition = aws_ecr_repository.main.name == "${var.project_name}-${var.environment}-backend" + error_message = "ECR repository name must follow format: {project}-{environment}-backend." + } +} + +run "test_ecr_scan_on_push" { + command = plan + + assert { + condition = aws_ecr_repository.main.image_scanning_configuration[0].scan_on_push == true + error_message = "ECR repository must have scan on push enabled." + } +} + +run "test_ecs_execution_role_name_format" { + command = plan + + assert { + condition = aws_iam_role.ecs_tasks_execution_role.name == "${var.project_name}-${var.environment}-ecs-tasks-execution-role" + error_message = "ECS execution role name must follow format: {project}-{environment}-ecs-tasks-execution-role." + } +} + +run "test_ecs_task_role_name_format" { + command = plan + + assert { + condition = aws_iam_role.ecs_task_role.name == "${var.project_name}-${var.environment}-ecs-task-role" + error_message = "ECS task role name must follow format: {project}-{environment}-ecs-task-role." + } +} + +run "test_eventbridge_role_name_format" { + command = plan + + assert { + condition = aws_iam_role.event_bridge_role.name == "${var.project_name}-${var.environment}-event-bridge-role" + error_message = "EventBridge role name must follow format: {project}-{environment}-event-bridge-role." + } +} + +run "test_ecs_ssm_policy_name_format" { + command = plan + + assert { + condition = aws_iam_policy.ecs_tasks_execution_role_ssm_policy.name == "${var.project_name}-${var.environment}-ecs-tasks-ssm-policy" + error_message = "ECS SSM policy name must follow format: {project}-{environment}-ecs-tasks-ssm-policy." + } +} + +run "test_ecs_execution_policy_name_format" { + command = plan + + assert { + condition = aws_iam_policy.ecs_tasks_execution_policy.name == "${var.project_name}-${var.environment}-ecs-tasks-execution-policy" + error_message = "ECS execution policy name must follow format: {project}-{environment}-ecs-tasks-execution-policy." + } +} + +run "test_eventbridge_policy_name_format" { + command = plan + + assert { + condition = aws_iam_policy.event_bridge_ecs_policy.name == "${var.project_name}-${var.environment}-event-bridge-ecs-policy" + error_message = "EventBridge policy name must follow format: {project}-{environment}-event-bridge-ecs-policy." + } +}