meal-order-manager/terraform/ecs.tf
Adam Moussa 3efff5e784
Some checks are pending
Deploy API / Resolve target (push) Waiting to run
Deploy API / Deploy API to (push) Blocked by required conditions
fix(infra): share the afterhours VPC in prod (PLAT-215) (#203)
* fix(infra): share the afterhours VPC when existing_vpc_id is set

Prod is at the account VPC quota, so the v1.0.0 apply destroyed Lambda/API Gateway then failed on CreateVpc. Skip creating a sixth VPC when the workspace supplies afterhours subnets.

* style(test): format the VPC terraform assertion for ruff
2026-09-21 21:15:47 +00:00

252 lines
7.2 KiB
HCL

# 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 = local.vpc_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 = local.vpc_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 = local.public_subnet_ids
drop_invalid_header_fields = true
}
resource "aws_lb_target_group" "api" {
name = "${local.project}-api"
port = 8080
protocol = "HTTP"
vpc_id = local.vpc_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 = local.public_subnet_ids
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)"
}