# Always-on afterhours API: Fargate behind an ALB. GitHub Actions owns the # image; Terraform ignores container_definitions after the bootstrap task # definition. Dual-run with API Gateway until the Fargate cutover. 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 afterhours-shift-manager" vpc_id = aws_vpc.this.id ingress { description = "HTTP from the internet (health and pre-DNS)" from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } dynamic "ingress" { for_each = var.attach_custom_domain ? [1] : [] content { description = "HTTPS from the internet" from_port = 443 to_port = 443 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 afterhours-shift-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" dynamic "default_action" { for_each = var.attach_custom_domain ? [1] : [] content { type = "redirect" redirect { port = "443" protocol = "HTTPS" status_code = "HTTP_301" } } } dynamic "default_action" { for_each = var.attach_custom_domain ? [] : [1] content { type = "forward" target_group_arn = aws_lb_target_group.api.arn } } } resource "aws_lb_listener" "https" { count = var.attach_custom_domain ? 1 : 0 load_balancer_arn = aws_lb.api.arn port = 443 protocol = "HTTPS" ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" certificate_arn = data.aws_acm_certificate.wildcard[0].arn 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 = "SHIFT_TABLE", value = aws_dynamodb_table.shifts.name }, { name = "SLACK_BOT_TOKEN_SECRET", value = "afterhours-shift-manager/slack-bot-token" }, { name = "SLACK_SIGNING_SECRET", value = "afterhours-shift-manager/slack-signing-secret" }, { name = "SHIFT_CHANNEL", value = var.shift_channel }, { name = "TCX_SECRET_PREFIX", value = "afterhours-shift-manager/3cx-" }, { name = "QUEUE_NUMBER", value = var.queue_number }, { name = "TZ", value = var.timezone }, { name = "HOLIDAY_SCHEDULER_ROLE_ARN", value = local.holiday_scheduler_role_arn }, { name = "HOLIDAY_SCHEDULE_GROUP", value = "default" }, { name = "SENTRY_DSN", value = var.sentry_dsn }, { name = "PORTAL_COGNITO_ISSUER", value = var.portal_cognito_issuer }, { name = "PORTAL_COGNITO_AUDIENCE", value = var.portal_cognito_audience }, { name = "PORTAL_COGNITO_TRUST", value = jsonencode(concat( var.portal_cognito_issuer != "" && var.portal_cognito_audience != "" ? [{ issuer = var.portal_cognito_issuer, audience = var.portal_cognito_audience }] : [], var.portal_cognito_extra_trust, )) }, { name = "PAY_REPORT_USER", value = var.pay_report_user }, { name = "CHECKCOMPONENTS_QUEUE_URL", value = var.checkcomponents_queue_url }, { name = "ROSTER_API_TOKEN_SECRET", value = "afterhours-shift-manager/roster-api-token" }, { name = "SYNC_GROUP", value = "DEFAULT" }, { name = "JOBS_QUEUE_URL", value = aws_sqs_queue.jobs.id }, { name = "JOBS_QUEUE_ARN", value = aws_sqs_queue.jobs.arn }, { name = "AWS_DEFAULT_REGION", value = var.aws_region }, ] } resource "aws_ecs_task_definition" "api" { family = local.project requires_compatibilities = ["FARGATE"] network_mode = "awsvpc" cpu = "512" memory = "1024" execution_role_arn = aws_iam_role.ecs_execution.arn task_role_arn = aws_iam_role.ecs_task.arn runtime_platform { operating_system_family = "LINUX" cpu_architecture = "ARM64" } 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" health_check_grace_period_seconds = 60 deployment_minimum_healthy_percent = local.is_prod ? 50 : 0 deployment_maximum_percent = 200 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 } 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 }) }