# ECS Fargate: one cluster, three services.
#
#   official-prod   2 tasks, public, read-only
#   staging         0 tasks by default, scaled to 1 for a test
#   poc             1 task, partner bearer token, write templates enabled
#
# Staging at zero is the cost decision that makes a third service affordable: an idle service costs
# nothing, and the alternative (testing against production) costs more than money.
#
# One thing this file assumes about the SERVER, worth stating because it was almost wrong: the
# default deployment configuration (100/200) means even a one-task service briefly runs TWO tasks
# during a deploy. The confirm flow is safe across tasks because its tokens are signed and carry
# everything the confirm needs, and only replay detection uses shared state. Nothing here may
# assume a single task.

resource "aws_ecs_cluster" "main" {
  name = "mcp"

  setting {
    name  = "containerInsights"
    value = "disabled" # Billed per metric; the CloudWatch alarms below cover what we watch.
  }
}

resource "aws_cloudwatch_log_group" "service" {
  for_each = toset(["official-prod", "staging", "poc"])

  name              = "/ecs/mcp-${each.key}"
  retention_in_days = var.log_retention_days
}

locals {
  # Smallest Fargate size. Traffic at launch is a handful of requests per second, and the server is
  # I/O bound on the TiDB Cloud API rather than CPU bound.
  task_cpu    = 256
  task_memory = 512

  # Configuration that genuinely belongs to the INFRASTRUCTURE: the port it listens on, and where
  # its secrets live. Everything else the application decides for itself.
  #
  # This boundary is deliberate and is what keeps the two repositories from having to change
  # together. Adding an application setting should be a change in the product repo alone. If a
  # routine change starts requiring an edit here, the boundary has slipped and the split has
  # started costing what it was supposed to avoid.
  common_env = [
    { name = "PORT", value = tostring(local.container_port) },
    { name = "NODE_ENV", value = "production" },

    # How many proxies sit between the internet and the container. One ALB, so one.
    #
    # This looks like an application setting and is not: it is a statement about the network this
    # file builds, and it is wrong the moment anything is put in front of the ALB. The application
    # cannot work it out for itself and deliberately refuses to guess, because guessing means
    # reading an `X-Forwarded-For` entry the CALLER wrote.
    #
    # Leaving it unset is not neutral. The server then ignores `X-Forwarded-For` entirely and keys
    # the pre-auth rate limit on the connection address, which behind an ALB is the ALB. Every
    # unproven caller lands in one bucket, and after each deploy tenants reprove at roughly one per
    # second and are refused until they do. It fails as a slow leak, not as an error, which is why
    # it is worth a paragraph. See docs/DEPLOY.md in the product repo.
    { name = "TIDB_MCP_TRUSTED_PROXY_DEPTH", value = "1" },
  ]
}

# ---------------------------------------------------------------------------------------------
# Task definitions
# ---------------------------------------------------------------------------------------------

resource "aws_ecs_task_definition" "official" {
  family                   = "mcp-official"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = local.task_cpu
  memory                   = local.task_memory
  execution_role_arn       = aws_iam_role.execution["official"].arn
  task_role_arn            = aws_iam_role.task["official"].arn

  container_definitions = jsonencode([
    {
      name      = "server"
      image     = "${aws_ecr_repository.this["official"].repository_url}:bootstrap"
      essential = true

      portMappings = [{ containerPort = local.container_port, protocol = "tcp" }]

      environment = concat(local.common_env, [
        # Read-only is set HERE as well as being the server's own default, because this is the
        # public endpoint and a shared endpoint that people may point a production key at must not
        # expose mutating tools. Two independent statements of the same intent is the right amount
        # for this one.
        { name = "TIDB_MCP_READ_ONLY", value = "true" },
        { name = "TIDB_MCP_REQUIRE_AUTH", value = "true" },
      ])

      secrets = [
        { name = "TELEMETRY_DSN", valueFrom = aws_secretsmanager_secret.this["mcp/official/telemetry-dsn"].arn },
        { name = "CONTROL_DSN", valueFrom = aws_secretsmanager_secret.this["mcp/official/control-dsn"].arn },
        { name = "CONFIRM_SECRET", valueFrom = aws_secretsmanager_secret.this["mcp/official/confirm-secret"].arn },
      ]

      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = aws_cloudwatch_log_group.service["official-prod"].name
          "awslogs-region"        = var.region
          "awslogs-stream-prefix" = "server"
        }
      }
    }
  ])

  lifecycle {
    # CI registers new revisions with the real image tag. Without this, every Terraform apply would
    # roll production back to whatever tag is written above.
    ignore_changes = [container_definitions]
  }
}

resource "aws_ecs_task_definition" "poc" {
  family                   = "mcp-poc"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = local.task_cpu
  memory                   = local.task_memory
  execution_role_arn       = aws_iam_role.execution["poc"].arn
  task_role_arn            = aws_iam_role.task["poc"].arn

  container_definitions = jsonencode([
    {
      name      = "server"
      image     = "${aws_ecr_repository.this["poc"].repository_url}:bootstrap"
      essential = true

      portMappings = [{ containerPort = local.container_port, protocol = "tcp" }]

      environment = concat(local.common_env, [
        { name = "TIDB_MCP_READ_ONLY", value = "false" },
        { name = "TIDB_MCP_REQUIRE_AUTH", value = "true" },
      ])

      secrets = [
        { name = "TIDB_MCP_REST_TOKEN", valueFrom = aws_secretsmanager_secret.this["mcp/poc/bearer-token"].arn },
        { name = "TELEMETRY_DSN", valueFrom = aws_secretsmanager_secret.this["mcp/poc/telemetry-dsn"].arn },
        { name = "CONTROL_DSN", valueFrom = aws_secretsmanager_secret.this["mcp/poc/control-dsn"].arn },
        { name = "CONFIRM_SECRET", valueFrom = aws_secretsmanager_secret.this["mcp/poc/confirm-secret"].arn },
      ]

      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = aws_cloudwatch_log_group.service["poc"].name
          "awslogs-region"        = var.region
          "awslogs-stream-prefix" = "server"
        }
      }
    }
  ])

  lifecycle {
    ignore_changes = [container_definitions]
  }
}

# ---------------------------------------------------------------------------------------------
# Services
# ---------------------------------------------------------------------------------------------

# There is NO `ordered_placement_strategy` in this file, and that is a correction rather than an
# omission. An earlier version spread official-prod across availability zones with one, and ECS
# rejected the service outright: placement strategies are an EC2 launch-type feature and Fargate
# refuses them. Nothing was lost by removing it. Fargate already distributes tasks across the
# subnets it is handed, so passing two subnets in two zones is what achieves the spread, and that
# was doing the work the whole time.

resource "aws_ecs_service" "official_prod" {
  name            = "mcp-official-prod"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.official.arn
  desired_count   = var.official_task_count
  launch_type     = "FARGATE"

  # The difference between "CI reports a failed deploy" and "CI reports a failed deploy and
  # production is already back on the previous image".
  #
  # Without this, a task that crashes on startup is retried indefinitely: CI sits in
  # `wait services-stable` for ten minutes, fails, and leaves someone to work out what to revert to
  # while the endpoint is degraded. ECS knows the answer already, so it should do it.
  deployment_circuit_breaker {
    enable   = true
    rollback = true
  }

  network_configuration {
    subnets          = aws_subnet.public[*].id
    security_groups  = [aws_security_group.task_official.id]
    assign_public_ip = true # No NAT; this is how the task reaches TiDB Cloud.
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.official_prod.arn
    container_name   = "server"
    container_port   = local.container_port
  }

  # Give the app time to be healthy before the ALB judges it. The first request also warms a
  # connection to the TiDB Cloud API.
  health_check_grace_period_seconds = 60

  lifecycle {
    # CI moves the service to new task-definition revisions. Terraform should not drag it back.
    ignore_changes = [task_definition, desired_count]
  }

  depends_on = [aws_lb_listener.official_http]
}

resource "aws_ecs_service" "staging" {
  name            = "mcp-staging"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.official.arn
  desired_count   = 0 # Scaled up for a test, then back down. Idle costs nothing.
  launch_type     = "FARGATE"

  # Enabled, but NOT rolling back. Staging exists to show a broken build, and a rollback would tidy
  # away the evidence and report a green service. What the breaker buys here is CI failing in about
  # a minute instead of sitting in `wait services-stable` for ten.
  deployment_circuit_breaker {
    enable   = true
    rollback = false
  }

  network_configuration {
    subnets          = aws_subnet.public[*].id
    security_groups  = [aws_security_group.task_official.id]
    assign_public_ip = true
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.official_staging.arn
    container_name   = "server"
    container_port   = local.container_port
  }

  health_check_grace_period_seconds = 60

  lifecycle {
    ignore_changes = [task_definition, desired_count]
  }

  # Depends on the RULE rather than the listener, and the difference is load-bearing.
  #
  # The staging target group reaches a load balancer only through `aws_lb_listener_rule.staging`,
  # and that rule is not created when `operator_cidrs` is empty. ECS refuses to attach a service to
  # a target group that belongs to no load balancer, so with an empty list this service cannot be
  # created at all. That coupling was missed when the rule was made conditional and the service was
  # not. Set `operator_cidrs` and staging works; leave it empty and this fails with "does not have
  # an associated load balancer", which is now at least a sentence someone can search for.
  depends_on = [aws_lb_listener_rule.staging]
}

resource "aws_ecs_service" "poc" {
  name            = "mcp-poc"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.poc.arn
  desired_count   = 1
  launch_type     = "FARGATE"

  # Rolls back, same as the official service. A partner is on the other end of this one.
  deployment_circuit_breaker {
    enable   = true
    rollback = true
  }

  network_configuration {
    subnets          = aws_subnet.public[*].id
    security_groups  = [aws_security_group.task_poc.id]
    assign_public_ip = true
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.poc.arn
    container_name   = "server"
    container_port   = local.container_port
  }

  health_check_grace_period_seconds = 60

  lifecycle {
    ignore_changes = [task_definition, desired_count]
  }

  depends_on = [aws_lb_listener.poc_http]
}
