From 560bd838b5e07005f3e9f7f5b6d0c37a1d708042 Mon Sep 17 00:00:00 2001 From: James Armes Date: Fri, 17 May 2024 19:09:52 -0400 Subject: [PATCH] Added AWS Fargate Service module. --- README.md | 14 +-- aws/fargate_service/README.md | 61 +++++++++++ aws/fargate_service/alb.tf | 100 ++++++++++++++++++ aws/fargate_service/data.tf | 9 ++ aws/fargate_service/iam.tf | 56 ++++++++++ aws/fargate_service/kms.tf | 16 +++ aws/fargate_service/local.tf | 4 + aws/fargate_service/logs.tf | 4 + aws/fargate_service/main.tf | 95 +++++++++++++++++ aws/fargate_service/output.tf | 12 +++ aws/fargate_service/providers.tf | 7 ++ .../container_definitions.yaml.tftpl | 14 +++ .../templates/execution-policy.yaml.tftpl | 24 +++++ .../templates/key-policy.yaml.tftpl | 30 ++++++ .../templates/repository-lifecycle.yaml.tftpl | 10 ++ .../task-execution-policy.yaml.tftpl | 24 +++++ aws/fargate_service/variables.tf | 81 ++++++++++++++ aws/fargate_service/versions.tf | 10 ++ aws/logging/README.md | 2 +- 19 files changed, 566 insertions(+), 7 deletions(-) create mode 100644 aws/fargate_service/README.md create mode 100644 aws/fargate_service/alb.tf create mode 100644 aws/fargate_service/data.tf create mode 100644 aws/fargate_service/iam.tf create mode 100644 aws/fargate_service/kms.tf create mode 100644 aws/fargate_service/local.tf create mode 100644 aws/fargate_service/logs.tf create mode 100644 aws/fargate_service/main.tf create mode 100644 aws/fargate_service/output.tf create mode 100644 aws/fargate_service/providers.tf create mode 100644 aws/fargate_service/templates/container_definitions.yaml.tftpl create mode 100644 aws/fargate_service/templates/execution-policy.yaml.tftpl create mode 100644 aws/fargate_service/templates/key-policy.yaml.tftpl create mode 100644 aws/fargate_service/templates/repository-lifecycle.yaml.tftpl create mode 100644 aws/fargate_service/templates/task-execution-policy.yaml.tftpl create mode 100644 aws/fargate_service/variables.tf create mode 100644 aws/fargate_service/versions.tf diff --git a/README.md b/README.md index fb09165..134543b 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,17 @@ terraform. ## Modules -| Provider | Module | Description | -|----------|--------------------------------------|--------------------------------------------------------------------------| -| AWS | [backend][aws-backend] | S3 storage backend for tfstate. | -| AWS | [cloudfront_waf][aws-cloudfront-waf] | CloudFront distribution that passes traffic through WAF without caching. | -| AWS | [logging][aws-logging] | CloudFront distribution that passes traffic through WAF without caching. | -| AWS | [vpc][aws-vpc] | CloudFront distribution that passes traffic through WAF without caching. | +| Provider | Module | Description | +|----------|----------------------------------------|--------------------------------------------------------------------------| +| AWS | [backend][aws-backend] | S3 storage backend for tfstate. | +| AWS | [cloudfront_waf][aws-cloudfront-waf] | CloudFront distribution that passes traffic through WAF without caching. | +| AWS | [fargate_service][aws-fargate_service] | CloudFront distribution that passes traffic through WAF without caching. | +| AWS | [logging][aws-logging] | CloudFront distribution that passes traffic through WAF without caching. | +| AWS | [vpc][aws-vpc] | CloudFront distribution that passes traffic through WAF without caching. | [aws-backend]: ./aws/backend/README.md [aws-cloudfront-waf]: ./aws/cloudfront_waf/README.md +[aws-fargate_service]: ./aws/fargate_service/README.md [aws-logging]: ./aws/logging/README.md [aws-vpc]: ./aws/vpc/README.md [opentofu]: https://opentofu.org/ diff --git a/aws/fargate_service/README.md b/aws/fargate_service/README.md new file mode 100644 index 0000000..b86f55b --- /dev/null +++ b/aws/fargate_service/README.md @@ -0,0 +1,61 @@ +# AWS Fargate Service Module + +This module launches a service on AWS Fargate. It creates a cluster, task +definition, service, and container repository. In addition, it creates the load +balancer, ACM certificate, Route53 records, and security groups needed to expose +the service. + +## Usage + +Add this module to your `main.tf` (or appropriate) file and configure the inputs +to match your desired configuration. For example: + +```hcl +module "cloudfront_waf" { + source = "github.com/codeforamerica/tofu-modules/aws/fargate_service" + + project = "my-project" + environment = "dev" +} +``` + +Make sure you re-run `tofu init` after adding the module to your configuration. + +```bash +tofu init +tofu plan +``` + +To update the source for this module, pass `-upgrade` to `tofu init`: + +```bash +tofu init -upgrade +``` + +## Inputs + +| Name | Description | Type | Default | Required | +|--------------------------|------------------------------------------------------------------------------------|----------|------------|----------| +| domain | Domain name for service. Example: `"staging.service.org"` | `string` | n/a | yes | +| logging_key_id | KMS key to use for log encryption. | `string` | n/a | yes | +| private_subnets | List of private subnet CIDR blocks. | `list` | n/a | yes | +| project | Name of the project. | `string` | n/a | yes | +| project_short | Short name for the project. Used in resource names with character limits. | `string` | n/a | yes | +| public_subnets | List of public subnet CIDR blocks. | `list` | n/a | yes | +| service | Service that these resources are supporting. Example: `"api"`, `"web"`, `"worker"` | `string` | n/a | yes | +| service_short | Short name for the service. Used in resource names with character limits. | `string` | n/a | yes | +| vpc_id | Id of the VPC to deploy into. | `string` | n/a | yes | +| container_port | Port the container listens on. | `number` | `80` | no | +| environment | Environment for the project. | `string` | `"dev"` | no | +| image_tag | Tag of the container image to be deployed. | `string` | `"latest"` | no | +| internal | Creates an internal ALB instead of a public one. | `bool` | `false` | no | +| key_recovery_period | Number of days to recover the service KMS key after deletion. | `number` | `30` | no | +| log_retention_period | Retention period for flow logs, in days. | `number` | `30` | no | +| untagged_image_retention | Retention period (after push) for untagged images, in days. | `number` | `14` | no | + +## Outputs + +| Name | Description | Type | +|--------------|--------------------------------------------------------------|----------| +| cluster_name | Name of the ECS Fargate cluster. | `string` | +| docker_push | Commands to push a Docker image to the container repository. | `string` | diff --git a/aws/fargate_service/alb.tf b/aws/fargate_service/alb.tf new file mode 100644 index 0000000..b24b6bd --- /dev/null +++ b/aws/fargate_service/alb.tf @@ -0,0 +1,100 @@ +module "alb" { + source = "terraform-aws-modules/alb/aws" + version = "~> 9.9" + enable_deletion_protection = false + + name = local.prefix_short + load_balancer_type = "application" + security_groups = [module.endpoint_security_group.security_group_id] + subnets = var.internal ? var.private_subnets : var.public_subnets + vpc_id = var.vpc_id + + # TODO: Support IPv6 and/or dualstack. + ip_address_type = "ipv4" + + listeners = { + http = { + port = 80 + protocol = "HTTP" + redirect = { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } + + https = { + port = 443 + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-TLS-1-2-2017-01" + certificate_arn = aws_acm_certificate.endpoint.arn + forward = { + target_group_key = "endpoint" + } + } + } + + target_groups = { + endpoint = { + name = "${local.prefix_short}-app" + protocol = "HTTP" + target_type = "ip" + port = var.container_port + + # Theres nothing to attach here in this definition. Instead, ECS will + # attach the IPs of the tasks to this target group. + create_attachment = false + + health_check = { + path = "/health" + healthy_threshold = 5 + unhealthy_threshold = 2 + } + } + } +} + +resource "aws_acm_certificate" "endpoint" { + domain_name = var.domain + validation_method = "DNS" + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_route53_record" "endpoint" { + name = var.domain + type = "A" + zone_id = data.aws_route53_zone.domain.zone_id + + alias { + name = module.alb.dns_name + zone_id = module.alb.zone_id + evaluate_target_health = true + } +} + +resource "aws_route53_record" "endpoint_validation" { + for_each = { + for dvo in aws_acm_certificate.endpoint.domain_validation_options : dvo.domain_name => { + name = dvo.resource_record_name + record = dvo.resource_record_value + type = dvo.resource_record_type + } + } + + allow_overwrite = true + name = each.value.name + records = [each.value.record] + ttl = 60 + type = each.value.type + zone_id = data.aws_route53_zone.domain.zone_id +} + +resource "aws_acm_certificate_validation" "endpoint" { + certificate_arn = aws_acm_certificate.endpoint.arn + validation_record_fqdns = [ + for record in aws_route53_record.endpoint_validation : record.fqdn + ] +} diff --git a/aws/fargate_service/data.tf b/aws/fargate_service/data.tf new file mode 100644 index 0000000..dd4ccff --- /dev/null +++ b/aws/fargate_service/data.tf @@ -0,0 +1,9 @@ +data "aws_caller_identity" "identity" {} + +data "aws_partition" "current" {} + +data "aws_region" "current" {} + +data "aws_route53_zone" "domain" { + name = var.domain +} diff --git a/aws/fargate_service/iam.tf b/aws/fargate_service/iam.tf new file mode 100644 index 0000000..5cfa3ee --- /dev/null +++ b/aws/fargate_service/iam.tf @@ -0,0 +1,56 @@ +resource "aws_iam_policy" "execution" { + name = "${local.prefix}-execution" + description = "${var.service} task execution policy for ${var.project} ${var.environment}." + + policy = jsonencode(yamldecode(templatefile("${path.module}/templates/execution-policy.yaml.tftpl", { + project = var.project + environment = var.environment + ecr_arn = module.ecr.repository_arn + }))) +} + +resource "aws_iam_role" "execution" { + name = "${local.prefix}-execution" + description = "${var.service} task execution role for ${var.project} ${var.environment}." + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + } + ] + }) + + managed_policy_arns = [ +# aws_iam_policy.execution.arn + "arn:${data.aws_partition.current.partition}:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy", + ] +} + +resource "aws_iam_role" "task" { + name = "${local.prefix}-task" + description = "${var.service} task role for ${var.project} ${var.environment}." + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + } + ] + }) + + managed_policy_arns = [ + "arn:${data.aws_partition.current.partition}:iam::aws:policy/CloudWatchFullAccess", + "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonSSMFullAccess" + ] +} diff --git a/aws/fargate_service/kms.tf b/aws/fargate_service/kms.tf new file mode 100644 index 0000000..80c8d0b --- /dev/null +++ b/aws/fargate_service/kms.tf @@ -0,0 +1,16 @@ +resource "aws_kms_key" "fargate" { + description = "${var.service} hosting encryption key for ${var.project} ${var.environment}" + deletion_window_in_days = var.key_recovery_period + enable_key_rotation = true + policy = jsonencode(yamldecode(templatefile("${path.module}/templates/key-policy.yaml.tftpl", { + account_id : data.aws_caller_identity.identity.account_id, + partition : data.aws_partition.current.partition, + region : data.aws_region.current.name, + repository_name : local.prefix, + }))) +} + +resource "aws_kms_alias" "fargate" { + name = "alias/${var.project}/${var.environment}/${var.service}" + target_key_id = aws_kms_key.fargate.id +} diff --git a/aws/fargate_service/local.tf b/aws/fargate_service/local.tf new file mode 100644 index 0000000..fbf6d5c --- /dev/null +++ b/aws/fargate_service/local.tf @@ -0,0 +1,4 @@ +locals { + prefix = "${var.project}-${var.environment}-${var.service}" + prefix_short = "${var.project_short}-${var.environment}-${var.service_short}" +} diff --git a/aws/fargate_service/logs.tf b/aws/fargate_service/logs.tf new file mode 100644 index 0000000..4e6ed87 --- /dev/null +++ b/aws/fargate_service/logs.tf @@ -0,0 +1,4 @@ +resource "aws_cloudwatch_log_group" "service" { + name = "/aws/ecs/${var.project}/${var.environment}/${var.service}" + retention_in_days = 30 +} diff --git a/aws/fargate_service/main.tf b/aws/fargate_service/main.tf new file mode 100644 index 0000000..92e55aa --- /dev/null +++ b/aws/fargate_service/main.tf @@ -0,0 +1,95 @@ +module "ecr" { + source = "terraform-aws-modules/ecr/aws" + version = "~> 2.2" + + repository_name = local.prefix + repository_image_scan_on_push = true + repository_encryption_type = "KMS" + repository_kms_key = aws_kms_key.fargate.arn + repository_lifecycle_policy = jsonencode(yamldecode(templatefile( + "${path.module}/templates/repository-lifecycle.yaml.tftpl", { + untagged_image_retention : var.untagged_image_retention + } + ))) +} + +# TODO: Configure internal CIDR +module "endpoint_security_group" { + source = "terraform-aws-modules/security-group/aws" + version = "~> 5.1" + + name = "${local.prefix}-endpoint" + vpc_id = var.vpc_id + + # Ingress for HTTP + ingress_cidr_blocks = [var.internal ? "10.0.0.0/16" : "0.0.0.0/0"] + ingress_rules = ["http-80-tcp", "https-443-tcp"] + + # Allow all egress + egress_cidr_blocks = ["0.0.0.0/0"] + egress_ipv6_cidr_blocks = ["::/0"] + egress_rules = ["all-all"] +} + +# TODO: Configure internal CIDR +module "task_security_group" { + source = "terraform-aws-modules/security-group/aws" + version = "~> 5.1" + + name = "${local.prefix}-endpoint" + vpc_id = var.vpc_id + + ingress_with_source_security_group_id = [{ + from_port = var.container_port + to_port = var.container_port + protocol = "tcp" + description = "${var.service} access from the load balancer." + source_security_group_id = module.endpoint_security_group.security_group_id + }] + + # Allow all egress + # TODO: Can we restrict this? + egress_cidr_blocks = ["0.0.0.0/0"] + egress_ipv6_cidr_blocks = ["::/0"] + egress_rules = ["all-all"] +} + +module "ecs" { + source = "HENNGE/ecs/aws" + version = "~> 4.2" + + name = local.prefix + capacity_providers = ["FARGATE"] + enable_container_insights = true +} + +module "ecs_service" { + source = "HENNGE/ecs/aws//modules/simple/fargate" + version = "~> 4.2" + depends_on = [module.alb, module.ecs] + + name = local.prefix + cluster = module.ecs.arn + container_port = var.container_port + container_name = local.prefix + cpu = 256 + memory = 512 + desired_count = 1 + vpc_subnets = var.private_subnets + target_group_arn = module.alb.target_groups["endpoint"].arn + security_groups = [module.task_security_group.security_group_id] + iam_daemon_role = aws_iam_role.execution.arn + iam_task_role = aws_iam_role.task.arn + + container_definitions = jsonencode(yamldecode(templatefile( + "${path.module}/templates/container_definitions.yaml.tftpl", { + name = local.prefix + cpu = 256 + memory = 512 + image = "${module.ecr.repository_url}:${var.image_tag}" + container_port = "${var.container_port}" + log_group = aws_cloudwatch_log_group.service.name + region = data.aws_region.current.name + } + ))) +} diff --git a/aws/fargate_service/output.tf b/aws/fargate_service/output.tf new file mode 100644 index 0000000..55edba7 --- /dev/null +++ b/aws/fargate_service/output.tf @@ -0,0 +1,12 @@ +output "cluster_name" { + value = module.ecs.name +} + +output "docker_push" { + value = < 6 && var.key_recovery_period < 31 + error_message = "Recovery period must be between 7 and 30." + } +} + +variable "private_subnets" { + type = list(string) + description = "List of private subnets." +} + +variable "project" { + type = string + description = "Project that these resources are supporting." +} + +variable "project_short" { + type = string + description = "Short name for the project. Used in resource names with character limits." +} + +variable "public_subnets" { + type = list(string) + description = "List of public subnets." +} + +variable "service" { + type = string + description = "Service that these resources are supporting. Example: 'api', 'web', 'worker'" +} + +variable "service_short" { + type = string + description = "Short name for the service. Used in resource names with character limits." +} + +variable "untagged_image_retention" { + type = number + default = 14 + description = "Retention period (after push) for untagged images, in days." +} + +variable "vpc_id" { + type = string + description = "Id of the VPC to deploy into." +} diff --git a/aws/fargate_service/versions.tf b/aws/fargate_service/versions.tf new file mode 100644 index 0000000..ff09141 --- /dev/null +++ b/aws/fargate_service/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.6" + + required_providers { + aws = { + version = ">= 5.44" + source = "hashicorp/aws" + } + } +} diff --git a/aws/logging/README.md b/aws/logging/README.md index 376063f..37573f6 100644 --- a/aws/logging/README.md +++ b/aws/logging/README.md @@ -39,7 +39,7 @@ tofu init -upgrade |---------------------|-------------------------------------------------------|----------|---------|----------| | project | Name of the project. | `string` | n/a | yes | | environment | Environment for the project. | `string` | `"dev"` | no | -| key_recovery_period | Number of days to recover the KMS key after deletion. | `number` | 30 | yes | +| key_recovery_period | Number of days to recover the KMS key after deletion. | `number` | `30` | yes | ## Outputs