# ECS Fargate service behind an internet-facing ALB. # Secrets (DB password, Django secret key) are injected from Secrets Manager; # no PHI or credentials live in the task definition itself. data "aws_caller_identity" "current" {} data "aws_partition" "current" {} locals { account_id = data.aws_caller_identity.current.account_id } resource "aws_ecr_repository" "app" { name = "${var.name}-access-core" image_tag_mutability = "IMMUTABLE" tags = var.tags } resource "aws_ecs_cluster" "this" { name = var.cluster_name tags = var.tags } # ---------------------------------------------------------------- IAM roles data "aws_iam_policy_document" "ecr_pull" { statement { actions = ["ecr:GetAuthorizationToken"] resources = ["*"] } statement { actions = ["ecr:BatchGetImage", "ecr:BatchCheckLayerAvailability", "ecr:GetDownloadUrlForLayer"] resources = [aws_ecr_repository.app.arn] } } data "aws_iam_policy_document" "cloudwatch_logs" { statement { actions = [ "logs:CreateLogStream", "logs:PutLogEvents", "logs:CreateLogGroup", "logs:DescribeLogGroups", "logs:DescribeLogStreams", ] resources = ["arn:${data.aws_partition.current.partition}:logs:*:${local.account_id}:log-group:/ecs/${var.cluster_name}*"] } } resource "aws_iam_role" "execution" { name = "${var.name}-fargate-execution" assume_role_policy = data.aws_iam_policy_document.ecs_tasks.json tags = var.tags } data "aws_iam_policy_document" "ecs_tasks" { statement { actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["ecs-tasks.amazonaws.com"] } } } resource "aws_iam_role_policy_attachment" "execution_ecr" { role = aws_iam_role.execution.name policy_arn = "arn:${data.aws_partition.current.partition}:iam::${local.account_id}:policy/service-role/AmazonECSTaskExecutionRolePolicy" } resource "aws_iam_role_policy" "execution_custom" { name = "${var.name}-fargate-execution-custom" role = aws_iam_role.execution.id policy = jsonencode([data.aws_iam_policy_document.ecr_pull, data.aws_iam_policy_document.cloudwatch_logs]) } resource "aws_iam_role" "task" { name = "${var.name}-fargate-task" assume_role_policy = data.aws_iam_policy_document.ecs_tasks.json tags = var.tags } data "aws_iam_policy_document" "task" { statement { sid = "SecretsManagerReadAppSecrets" actions = ["secretsmanager:GetSecretValue"] resources = [var.db_secret_arn, var.app_secrets_arn] } dynamic "statement" { for_each = var.s3_bucket_arn != "" ? [1] : [] content { sid = "S3AppArtifacts" actions = ["s3:GetObject", "s3:PutObject"] resources = ["${var.s3_bucket_arn}/*"] } } dynamic "statement" { for_each = var.queue_arn != "" ? [1] : [] content { sid = "SQSWebhookQueue" actions = ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"] resources = [var.queue_arn, "${var.queue_arn}/*"] } } dynamic "statement" { for_each = var.kms_key_arn != "" ? [1] : [] content { sid = "KMSPhiAccess" actions = ["kms:Encrypt", "kms:Decrypt", "kms:DescribeKey"] resources = [var.kms_key_arn, var.pseudonymization_key_arn] } } dynamic "statement" { for_each = var.enable_kms_queue_access ? [1] : [] content { sid = "KMSQueueAccess" actions = ["kms:Decrypt", "kms:GenerateDataKey*", "kms:DescribeKey"] resources = [var.queue_kms_key_arn] } } statement { sid = "CloudWatchLogsApp" actions = ["logs:CreateLogStream", "logs:PutLogEvents"] resources = [aws_cloudwatch_log_group.app.arn, "${aws_cloudwatch_log_group.app.arn}/*"] } } resource "aws_iam_role_policy" "task" { name = "${var.name}-fargate-task-policy" role = aws_iam_role.task.id policy = data.aws_iam_policy_document.task.json } # ---------------------------------------------------------------- log group resource "aws_cloudwatch_log_group" "app" { name = "/ecs/${var.cluster_name}/access-core" retention_in_days = var.log_retention_days tags = var.tags } resource "aws_cloudwatch_log_metric_filter" "http_5xx" { count = var.enable_alarms ? 1 : 0 name = "${var.name}-access-core-5xx" # gunicorn default access format: %h %l %u %t "%r" %>s %b — the [timestamp] # field spans two tokens, so the status code is token 9. Trailing bytes are # allowed by CloudWatch prefix matching. pattern = "? ? ? [ ? ? ? ? 5??" log_group_name = aws_cloudwatch_log_group.app.name metric_transformation { name = "Http5xxCount" namespace = "AccessCore/${var.environment}" value = "1" } } resource "aws_cloudwatch_metric_alarm" "http_5xx" { count = var.enable_alarms ? 1 : 0 alarm_name = "${var.name}-access-core-5xx-burst" comparison_operator = "GreaterThanThreshold" evaluation_periods = 2 metric_name = "Http5xxCount" namespace = "AccessCore/${var.environment}" period = 300 statistic = "Sum" threshold = 10 alarm_actions = var.alarm_action_arns } resource "aws_cloudwatch_metric_alarm" "queue_depth" { count = var.enable_alarms && var.queue_arn != "" ? 1 : 0 alarm_name = "${var.name}-webhook-queue-depth" comparison_operator = "GreaterThanThreshold" evaluation_periods = 3 metric_name = "ApproximateNumberOfMessagesVisible" namespace = "AWS/SQS" period = 60 statistic = "Sum" threshold = var.queue_depth_alarm_threshold # SQS alarm dimension is the queue name (6th ARN segment), not the full ARN. dimensions = { QueueName = split(":", var.queue_arn)[5] } alarm_actions = var.alarm_action_arns } # Permanent Healthie GraphQL fetch failures (e.g., expired API key). These ack # with 200 to Healthie, so the gunicorn 5xx filter above never sees them. The # pattern matches the stable HEALTHIE_FETCH_PERMANENT_FAILURE token emitted by # access_core/intake/consumers.py; bracketed wildcards absorb any prefix/suffix # tokens so it works for both plain and JSON log formats. resource "aws_cloudwatch_log_metric_filter" "healthie_fetch_failure" { count = var.enable_alarms ? 1 : 0 name = "${var.name}-access-core-healthie-fetch-failure" pattern = "[*] HEALTHIE_FETCH_PERMANENT_FAILURE [*]" log_group_name = aws_cloudwatch_log_group.app.name metric_transformation { name = "HealthieFetchFailureCount" namespace = "AccessCore/${var.environment}" value = "1" } } # A single permanent failure means every subsequent webhook will fail until the # key is rotated, so alert on the first occurrence (no burst threshold). resource "aws_cloudwatch_metric_alarm" "healthie_fetch_failure" { count = var.enable_alarms ? 1 : 0 alarm_name = "${var.name}-access-core-healthie-fetch-failure" comparison_operator = "GreaterThanThreshold" evaluation_periods = 1 metric_name = "HealthieFetchFailureCount" namespace = "AccessCore/${var.environment}" period = 300 statistic = "Sum" threshold = 1 alarm_actions = var.alarm_action_arns } # ---------------------------------------------------------------- network resource "aws_security_group" "alb" { name = "${var.name}-alb-sg" vpc_id = var.vpc_id tags = merge(var.tags, { Name = "${var.name}-alb-sg" }) # Port 80 is needed both for the HTTP->HTTPS redirect (cert mode) and as the # only listener in dev/staging HTTP-only mode, so it must be open either way. ingress { description = "HTTP from anywhere (redirect or HTTP-only)" protocol = "tcp" from_port = 80 to_port = 80 cidr_blocks = ["0.0.0.0/0"] } ingress { description = "HTTPS from anywhere (TLS terminated at ALB)" protocol = "tcp" from_port = 443 to_port = 443 cidr_blocks = ["0.0.0.0/0"] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } } resource "aws_security_group" "fargate" { name = "${var.name}-fargate-sg" vpc_id = var.vpc_id tags = merge(var.tags, { Name = "${var.name}-fargate-sg" }) ingress { description = "App port from ALB only" protocol = "tcp" from_port = var.container_port to_port = var.container_port security_groups = [aws_security_group.alb.id] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } } resource "aws_lb" "this" { name = "${var.name}-access-core-alb" load_balancer_type = "application" subnets = var.public_subnet_ids security_groups = [aws_security_group.alb.id] drop_invalid_header_fields = true tags = var.tags } resource "aws_lb_target_group" "app" { name = "${var.name}-access-core-tg" port = var.container_port protocol = "HTTP" vpc_id = var.vpc_id target_type = "ip" health_check { path = "/health/" healthy_threshold = 2 unhealthy_threshold = 3 timeout = 5 interval = 30 } tags = var.tags } resource "aws_lb_listener" "https" { count = var.alb_certificate_arn != "" ? 1 : 0 load_balancer_arn = aws_lb.this.arn port = 443 protocol = "HTTPS" ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" certificate_arn = var.alb_certificate_arn default_action { type = "forward" target_group_arn = aws_lb_target_group.app.arn } } resource "aws_lb_listener" "http_redirect" { count = var.alb_certificate_arn != "" ? 1 : 0 load_balancer_arn = aws_lb.this.arn port = 80 protocol = "HTTP" default_action { type = "redirect" redirect { port = "443" protocol = "HTTPS" status_code = "HTTP_301" } } } # HTTP-only mode (no ACM certificate yet): forward port 80 straight to the app. resource "aws_lb_listener" "http_only" { count = var.alb_certificate_arn == "" ? 1 : 0 load_balancer_arn = aws_lb.this.arn port = 80 protocol = "HTTP" default_action { type = "forward" target_group_arn = aws_lb_target_group.app.arn } } # ---------------------------------------------------------------- task def + service resource "aws_ecs_task_definition" "app" { family = "${var.name}-access-core" requires_compatibilities = ["FARGATE"] network_mode = "awsvpc" cpu = var.task_cpu memory = var.task_memory execution_role_arn = aws_iam_role.execution.arn task_role_arn = aws_iam_role.task.arn container_definitions = jsonencode([ { name = "access-core" image = "${local.account_id}.dkr.ecr.${var.region}.amazonaws.com/${aws_ecr_repository.app.name}:${var.image_tag}" essential = true portMappings = [ { containerPort = var.container_port } ] environment = merge( { DJANGO_SETTINGS_MODULE = var.django_settings_module PORT = tostring(var.container_port) GUNICORN_WORKERS = "2" POSTGRES_HOST = var.postgres_host POSTGRES_DB = var.postgres_db POSTGRES_USER = var.postgres_user }, var.extra_environment ) secrets = [ { name = "POSTGRES_PASSWORD", valueFrom = "${var.db_secret_arn}:password" }, { name = "DJANGO_SECRET_KEY", valueFrom = "${var.app_secrets_arn}:django-secret-key" } ] logConfiguration = { logDriver = "awslogs" options = { "awslogs-group" = aws_cloudwatch_log_group.app.name "awslogs-region" = var.region "awslogs-stream-prefix" = "access-core" } } } ]) tags = var.tags } resource "aws_ecs_service" "this" { name = "${var.name}-web" cluster = aws_ecs_cluster.this.id task_definition = aws_ecs_task_definition.app.arn desired_count = var.desired_count launch_type = "FARGATE" network_configuration { subnets = var.private_subnet_ids security_groups = [aws_security_group.fargate.id] assign_public_ip = false } load_balancer { target_group_arn = aws_lb_target_group.app.arn container_name = "access-core" container_port = var.container_port } deployment_maximum_percent = 200 deployment_minimum_healthy_percent = 100 tags = var.tags } resource "aws_appautoscaling_target" "this" { count = var.enable_autoscaling ? 1 : 0 max_capacity = var.autoscale_max min_capacity = var.autoscale_min resource_id = "service/${aws_ecs_cluster.this.name}/${aws_ecs_service.this.name}" scalable_dimension = "ecs:service:DesiredCount" service_namespace = "ecs" } resource "aws_appautoscaling_policy" "cpu" { count = var.enable_autoscaling ? 1 : 0 name = "${var.name}-access-core-cpu" policy_type = "TargetTrackingScaling" resource_id = aws_appautoscaling_target.this[0].resource_id scalable_dimension = aws_appautoscaling_target.this[0].scalable_dimension service_namespace = "ecs" target_tracking_scaling_policy_configuration { target_value = 60.0 predefined_metric_specification { predefined_metric_type = "ECSServiceAverageCPUUtilization" } } } # ---------------------------------------------------------------- worker service # # Single-instance CMS polling worker: loops `manage.py cms_polling_worker` # (which runs poll_cms_submissions every CMS_POLL_LOOP_SECONDS). desired_count=1 # keeps exactly one poller active; submission rows are additionally self-gating # via next_poll_at, so a brief overlap during a redeploy is harmless. resource "aws_security_group" "worker" { count = var.enable_worker ? 1 : 0 name = "${var.name}-worker-sg" vpc_id = var.vpc_id tags = merge(var.tags, { Name = "${var.name}-worker-sg" }) # No inbound: the worker only initiates outbound calls (RDS + CMS API). egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } } resource "aws_ecs_task_definition" "worker" { count = var.enable_worker ? 1 : 0 family = "${var.name}-access-core-worker" requires_compatibilities = ["FARGATE"] network_mode = "awsvpc" cpu = 256 memory = 256 execution_role_arn = aws_iam_role.execution.arn task_role_arn = aws_iam_role.task.arn container_definitions = jsonencode([ { name = "access-core-worker" image = "${local.account_id}.dkr.ecr.${var.region}.amazonaws.com/${aws_ecr_repository.app.name}:${var.image_tag}" essential = true # Overrides the image CMD (gunicorn); the final stage has no ENTRYPOINT. command = ["python", "manage.py", "cms_polling_worker"] environment = merge( { DJANGO_SETTINGS_MODULE = var.django_settings_module POSTGRES_HOST = var.postgres_host POSTGRES_DB = var.postgres_db POSTGRES_USER = var.postgres_user }, var.extra_environment ) secrets = [ { name = "POSTGRES_PASSWORD", valueFrom = "${var.db_secret_arn}:password" }, { name = "DJANGO_SECRET_KEY", valueFrom = "${var.app_secrets_arn}:django-secret-key" } ] logConfiguration = { logDriver = "awslogs" options = { "awslogs-group" = aws_cloudwatch_log_group.app.name "awslogs-region" = var.region "awslogs-stream-prefix" = "access-core-worker" } } } ]) tags = var.tags } resource "aws_ecs_service" "worker" { count = var.enable_worker ? 1 : 0 name = "${var.name}-worker" cluster = aws_ecs_cluster.this.id task_definition = aws_ecs_task_definition.worker[0].arn desired_count = 1 launch_type = "FARGATE" network_configuration { subnets = var.private_subnet_ids security_groups = [aws_security_group.worker[0].id] assign_public_ip = false } # No load balancer: drain the old task immediately on update so two pollers # are never scheduled at once in steady state. deployment_maximum_percent = 100 deployment_minimum_healthy_percent = 0 tags = var.tags }