2026-09-21 19:13:30 +00:00
|
|
|
# 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"
|
2026-09-21 19:30:54 +00:00
|
|
|
vpc_id = aws_vpc.this.id
|
2026-09-21 19:13:30 +00:00
|
|
|
|
|
|
|
|
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"
|
2026-09-21 19:30:54 +00:00
|
|
|
vpc_id = aws_vpc.this.id
|
2026-09-21 19:13:30 +00:00
|
|
|
|
|
|
|
|
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]
|
2026-09-21 19:30:54 +00:00
|
|
|
subnets = aws_subnet.public[*].id
|
2026-09-21 19:13:30 +00:00
|
|
|
|
|
|
|
|
drop_invalid_header_fields = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resource "aws_lb_target_group" "api" {
|
|
|
|
|
name = "${local.project}-api"
|
|
|
|
|
port = 8080
|
|
|
|
|
protocol = "HTTP"
|
2026-09-21 19:30:54 +00:00
|
|
|
vpc_id = aws_vpc.this.id
|
2026-09-21 19:13:30 +00:00
|
|
|
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" },
|
2026-09-29 19:10:21 +00:00
|
|
|
{ name = "SHIFT_CHANNEL", value = local.is_prod ? var.shift_channel : "" },
|
|
|
|
|
{ name = "TCX_SECRET_PREFIX", value = local.is_prod ? "afterhours-shift-manager/3cx-" : "" },
|
|
|
|
|
{ name = "QUEUE_NUMBER", value = local.is_prod ? var.queue_number : "" },
|
2026-09-21 19:13:30 +00:00
|
|
|
{ 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,
|
|
|
|
|
)) },
|
2026-09-29 19:10:21 +00:00
|
|
|
{ name = "PAY_REPORT_USER", value = local.is_prod ? var.pay_report_user : "" },
|
2026-09-21 19:13:30 +00:00
|
|
|
{ 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 {
|
2026-09-21 19:30:54 +00:00
|
|
|
subnets = aws_subnet.public[*].id
|
2026-09-21 19:13:30 +00:00
|
|
|
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
|
|
|
|
|
})
|
|
|
|
|
}
|