# Always-on meals API: Fargate behind an ALB. GitHub Actions owns the image; # Terraform ignores container_definitions after the bootstrap task definition. resource "aws_ecr_repository" "api" { name = local.project image_tag_mutability = "MUTABLE" force_delete = !local.is_prod image_scanning_configuration { scan_on_push = true } encryption_configuration { encryption_type = "AES256" } } resource "aws_ecr_lifecycle_policy" "api" { repository = aws_ecr_repository.api.name policy = jsonencode({ rules = [ { rulePriority = 1 description = "Keep the last 20 images" selection = { tagStatus = "any" countType = "imageCountMoreThan" countNumber = 20 } action = { type = "expire" } } ] }) } resource "aws_security_group" "alb" { name = "${local.project}-alb" description = "Public ALB for meal-order-manager" vpc_id = aws_vpc.this.id ingress { # CloudFront prefix lists cannot cover GitHub-hosted weekly-menu HMAC # publish, so this ALB is internet-reachable on :80. Flask HMAC and # Bearer checks are the application gate. Security review required. description = "HTTP from CloudFront and weekly-menu HMAC publish" from_port = 80 to_port = 80 protocol = "tcp" 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" "api" { name = "${local.project}-api" description = "Fargate tasks for meal-order-manager" vpc_id = aws_vpc.this.id ingress { description = "From ALB" from_port = 8080 to_port = 8080 protocol = "tcp" 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" "api" { name = local.project load_balancer_type = "application" idle_timeout = 120 security_groups = [aws_security_group.alb.id] subnets = aws_subnet.public[*].id drop_invalid_header_fields = true } resource "aws_lb_target_group" "api" { name = "${local.project}-api" port = 8080 protocol = "HTTP" vpc_id = aws_vpc.this.id target_type = "ip" health_check { enabled = true path = "/api/health" matcher = "200" interval = 30 timeout = 5 healthy_threshold = 2 unhealthy_threshold = 3 } } resource "aws_lb_listener" "http" { load_balancer_arn = aws_lb.api.arn port = 80 protocol = "HTTP" default_action { type = "forward" target_group_arn = aws_lb_target_group.api.arn } } resource "aws_ecs_cluster" "api" { name = local.project setting { name = "containerInsights" value = local.is_prod ? "enabled" : "disabled" } } locals { api_container_name = "api" bootstrap_command = [ "python", "-c", "from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler\nclass H(BaseHTTPRequestHandler):\n def do_GET(self):\n body = b'{\"stage\":\"bootstrap\",\"sha\":\"bootstrap\"}'\n self.send_response(200)\n self.send_header('Content-Type', 'application/json')\n self.send_header('Content-Length', str(len(body)))\n self.end_headers()\n self.wfile.write(body)\nThreadingHTTPServer(('0.0.0.0', 8080), H).serve_forever()", ] api_environment = [ { name = "STAGE", value = var.environment }, { name = "GIT_SHA", value = "bootstrap" }, { name = "TABLE_NAME", value = local.table_name }, { name = "REPORTS_BUCKET", value = local.reports_bucket_name }, { name = "SLACK_CHANNEL_PARAM", value = local.slack_channel_param }, { name = "FORM_URL", value = local.form_url }, { name = "SLACK_BOT_SM_NAME", value = local.slack_bot_secret_name }, { name = "GOOGLE_CLIENT_ID_PARAM", value = local.google_client_id_param }, { name = "PORTAL_COGNITO_ISSUER_PARAM", value = aws_ssm_parameter.portal_cognito_issuer.name }, { name = "PORTAL_COGNITO_AUDIENCE_PARAM", value = aws_ssm_parameter.portal_cognito_audience.name }, { name = "PORTAL_COGNITO_TRUST_PARAM", value = aws_ssm_parameter.portal_cognito_trust.name }, { name = "PUBLISH_KEY_PARAM", value = aws_ssm_parameter.publish_key.name }, { name = "JOBS_QUEUE_URL", value = aws_sqs_queue.jobs.id }, { name = "CHECKCOMPONENTS_QUEUE_URL", value = var.checkcomponents_queue_url }, { name = "AWS_DEFAULT_REGION", value = var.aws_region }, ] } resource "aws_ecs_task_definition" "api" { family = local.project requires_compatibilities = ["FARGATE"] network_mode = "awsvpc" cpu = "256" memory = "512" execution_role_arn = aws_iam_role.ecs_execution.arn task_role_arn = aws_iam_role.ecs_task.arn container_definitions = jsonencode([ { name = local.api_container_name image = "public.ecr.aws/docker/library/python:3.12-slim" essential = true command = local.bootstrap_command portMappings = [ { containerPort = 8080 protocol = "tcp" } ] environment = local.api_environment logConfiguration = { logDriver = "awslogs" options = { "awslogs-group" = aws_cloudwatch_log_group.api.name "awslogs-region" = var.aws_region "awslogs-stream-prefix" = "ecs" } } } ]) lifecycle { ignore_changes = [container_definitions] } } resource "aws_ecs_service" "api" { name = local.project cluster = aws_ecs_cluster.api.id task_definition = aws_ecs_task_definition.api.arn desired_count = local.is_prod ? 2 : 1 launch_type = "FARGATE" network_configuration { subnets = aws_subnet.public[*].id security_groups = [aws_security_group.api.id] assign_public_ip = true } load_balancer { target_group_arn = aws_lb_target_group.api.arn container_name = local.api_container_name container_port = 8080 } health_check_grace_period_seconds = 60 deployment_minimum_healthy_percent = local.is_prod ? 50 : 0 deployment_maximum_percent = 200 lifecycle { ignore_changes = [task_definition, desired_count] } depends_on = [aws_lb_listener.http] } resource "aws_sqs_queue" "jobs_dlq" { name = "${local.project}-jobs-dlq" message_retention_seconds = 1209600 } resource "aws_sqs_queue" "jobs" { name = "${local.project}-jobs" visibility_timeout_seconds = 180 receive_wait_time_seconds = 20 redrive_policy = jsonencode({ deadLetterTargetArn = aws_sqs_queue.jobs_dlq.arn maxReceiveCount = 3 }) } resource "random_password" "publish_key" { length = 48 special = false } resource "aws_ssm_parameter" "publish_key" { name = "${local.ssm_prefix}/publish-key" type = "SecureString" value = random_password.publish_key.result description = "Shared secret for /api/publish/* (weekly-menu HMAC)" }