AWSTemplateFormatVersion: '2010-09-09'
Description: Amazon ECS Cluster and Service Cost Review Remediator - review-only inventory across enabled Regions.

Parameters:
  ScheduleEnabled:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
  ScheduleExpression:
    Type: String
    Default: cron(0 13 ? * MON *)
  ExcludedRegionsCsv:
    Type: String
    Default: ap-northeast-3
  IncludeClusterDetails:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
  IncludeServiceDetails:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
  IncludeTaskSamples:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
  IncludeTags:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
  MaxClustersPerRegion:
    Type: Number
    Default: 100
    MinValue: 1
    MaxValue: 500
  MaxServicesPerCluster:
    Type: Number
    Default: 200
    MinValue: 1
    MaxValue: 500
  MaxTasksPerCluster:
    Type: Number
    Default: 100
    MinValue: 1
    MaxValue: 500

Conditions:
  CreateSchedule: !Equals [!Ref ScheduleEnabled, 'true']

Resources:
  EcsCostReviewRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: [lambda.amazonaws.com]
            Action: [sts:AssumeRole]
      Policies:
        - PolicyName: ecs-cost-review-policy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: LambdaLogging
                Effect: Allow
                Action: [logs:CreateLogGroup, logs:CreateLogStream, logs:PutLogEvents]
                Resource: '*'
              - Sid: RegionDiscovery
                Effect: Allow
                Action: [ec2:DescribeRegions]
                Resource: '*'
              - Sid: EcsReadOnly
                Effect: Allow
                Action:
                  - ecs:ListClusters
                  - ecs:DescribeClusters
                  - ecs:ListServices
                  - ecs:DescribeServices
                  - ecs:ListTasks
                  - ecs:DescribeTasks
                  - ecs:ListContainerInstances
                  - ecs:DescribeContainerInstances
                  - ecs:ListTagsForResource
                Resource: '*'

  EcsCostReviewFunction:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: python3.12
      Handler: index.lambda_handler
      Role: !GetAtt EcsCostReviewRole.Arn
      Timeout: 900
      MemorySize: 256
      Environment:
        Variables:
          EXCLUDED_REGIONS_CSV: !Ref ExcludedRegionsCsv
          INCLUDE_CLUSTER_DETAILS: !Ref IncludeClusterDetails
          INCLUDE_SERVICE_DETAILS: !Ref IncludeServiceDetails
          INCLUDE_TASK_SAMPLES: !Ref IncludeTaskSamples
          INCLUDE_TAGS: !Ref IncludeTags
          MAX_CLUSTERS_PER_REGION: !Ref MaxClustersPerRegion
          MAX_SERVICES_PER_CLUSTER: !Ref MaxServicesPerCluster
          MAX_TASKS_PER_CLUSTER: !Ref MaxTasksPerCluster
      Code:
        ZipFile: |
          import boto3
          import os
          import traceback
          from datetime import datetime, timezone
          from botocore.exceptions import ClientError, EndpointConnectionError

          def now():
              return datetime.now(timezone.utc).isoformat()

          def as_bool(value, default=False):
              if value is None:
                  return default
              return str(value).strip().lower() in ("1", "true", "yes", "y")

          def as_int(value, default):
              try:
                  return int(value)
              except Exception:
                  return default

          def csv_set(value):
              return set(x.strip() for x in str(value or "").split(",") if x.strip())

          def enabled_regions():
              ec2 = boto3.client("ec2")
              response = ec2.describe_regions(AllRegions=True)
              regions = []
              for r in response.get("Regions", []):
                  status = r.get("OptInStatus")
                  if status in (None, "opt-in-not-required", "opted-in"):
                      regions.append(r["RegionName"])
              return sorted(regions)

          def chunked(items, size):
              for i in range(0, len(items), size):
                  yield items[i:i + size]

          def list_with_token(fn, key, max_items, **kwargs):
              items = []
              token = None
              while True:
                  call = dict(kwargs)
                  if token:
                      call["nextToken"] = token
                  response = fn(**call)
                  items.extend(response.get(key, []) or [])
                  if len(items) >= max_items:
                      return items[:max_items], True
                  token = response.get("nextToken")
                  if not token:
                      return items, False

          def describe_clusters(client, cluster_arns, include_tags):
              clusters = []
              include = ["SETTINGS", "STATISTICS", "CONFIGURATIONS"]
              if include_tags:
                  include.append("TAGS")
              for chunk in chunked(cluster_arns, 100):
                  if not chunk:
                      continue
                  response = client.describe_clusters(clusters=chunk, include=include)
                  clusters.extend(response.get("clusters", []) or [])
              return clusters

          def describe_services(client, cluster_arn, service_arns, include_tags):
              services = []
              include = ["TAGS"] if include_tags else []
              for chunk in chunked(service_arns, 10):
                  if not chunk:
                      continue
                  kwargs = {"cluster": cluster_arn, "services": chunk}
                  if include:
                      kwargs["include"] = include
                  response = client.describe_services(**kwargs)
                  services.extend(response.get("services", []) or [])
              return services

          def describe_tasks(client, cluster_arn, task_arns, include_tags):
              tasks = []
              include = ["TAGS"] if include_tags else []
              for chunk in chunked(task_arns, 100):
                  if not chunk:
                      continue
                  kwargs = {"cluster": cluster_arn, "tasks": chunk}
                  if include:
                      kwargs["include"] = include
                  response = client.describe_tasks(**kwargs)
                  tasks.extend(response.get("tasks", []) or [])
              return tasks

          def tags_to_dict(tags):
              if isinstance(tags, list):
                  return {t.get("key"): t.get("value") for t in tags if t.get("key")}
              return {}

          def get_stat(cluster, name):
              for s in cluster.get("statistics", []) or []:
                  if s.get("name") == name:
                      try:
                          return int(s.get("value", 0))
                      except Exception:
                          return s.get("value")
              return 0

          def summarize_service(region, cluster_arn, svc):
              deployments = svc.get("deployments") or []
              load_balancers = svc.get("loadBalancers") or []
              network_config = svc.get("networkConfiguration") or {}
              awsvpc = network_config.get("awsvpcConfiguration") or {}
              capacity_provider_strategy = svc.get("capacityProviderStrategy") or []
              launch_type = svc.get("launchType")
              item = {
                  "region": region,
                  "cluster_arn": cluster_arn,
                  "service_name": svc.get("serviceName"),
                  "service_arn": svc.get("serviceArn"),
                  "status": svc.get("status"),
                  "launch_type": launch_type,
                  "capacity_provider_strategy_count": len(capacity_provider_strategy),
                  "capacity_provider_strategy": capacity_provider_strategy,
                  "desired_count": svc.get("desiredCount", 0),
                  "running_count": svc.get("runningCount", 0),
                  "pending_count": svc.get("pendingCount", 0),
                  "deployment_count": len(deployments),
                  "task_definition": svc.get("taskDefinition"),
                  "platform_version": svc.get("platformVersion"),
                  "scheduling_strategy": svc.get("schedulingStrategy"),
                  "propagate_tags": svc.get("propagateTags"),
                  "enable_ecs_managed_tags": svc.get("enableECSManagedTags"),
                  "enable_execute_command": svc.get("enableExecuteCommand"),
                  "load_balancer_count": len(load_balancers),
                  "awsvpc_subnet_count": len(awsvpc.get("subnets") or []),
                  "awsvpc_security_group_count": len(awsvpc.get("securityGroups") or []),
                  "assign_public_ip": awsvpc.get("assignPublicIp"),
                  "tags": tags_to_dict(svc.get("tags") or []),
                  "review_flags": []
              }

              flags = []
              if item["status"] == "ACTIVE":
                  flags.append("active_service_review")
              if item["desired_count"] > 0:
                  flags.append("desired_tasks_nonzero_review")
              if item["running_count"] > 0:
                  flags.append("running_tasks_review")
              if item["pending_count"] > 0:
                  flags.append("pending_tasks_review")
              if item["deployment_count"] > 1:
                  flags.append("multiple_deployments_review")
              if launch_type == "FARGATE":
                  flags.append("fargate_service_review")
              if launch_type == "EC2":
                  flags.append("ec2_service_review")
              if capacity_provider_strategy:
                  flags.append("capacity_provider_strategy_review")
              if item["assign_public_ip"] == "ENABLED":
                  flags.append("public_ip_assignment_review")
              if item["enable_execute_command"] is True:
                  flags.append("execute_command_enabled_review")
              item["review_flags"] = sorted(set(flags))
              return item

          def summarize_task(region, task):
              containers = task.get("containers") or []
              item = {
                  "region": region,
                  "cluster_arn": task.get("clusterArn"),
                  "task_arn": task.get("taskArn"),
                  "launch_type": task.get("launchType"),
                  "capacity_provider_name": task.get("capacityProviderName"),
                  "last_status": task.get("lastStatus"),
                  "desired_status": task.get("desiredStatus"),
                  "task_definition_arn": task.get("taskDefinitionArn"),
                  "platform_version": task.get("platformVersion"),
                  "cpu": task.get("cpu"),
                  "memory": task.get("memory"),
                  "created_at": str(task.get("createdAt")) if task.get("createdAt") else None,
                  "started_at": str(task.get("startedAt")) if task.get("startedAt") else None,
                  "stopped_at": str(task.get("stoppedAt")) if task.get("stoppedAt") else None,
                  "container_count": len(containers),
                  "tags": tags_to_dict(task.get("tags") or [])
              }
              return item

          def summarize_cluster(region, cluster):
              capacity_providers = cluster.get("capacityProviders") or []
              default_strategy = cluster.get("defaultCapacityProviderStrategy") or []
              settings = {s.get("name"): s.get("value") for s in (cluster.get("settings") or [])}
              item = {
                  "region": region,
                  "cluster_name": cluster.get("clusterName"),
                  "cluster_arn": cluster.get("clusterArn"),
                  "status": cluster.get("status"),
                  "registered_container_instances_count": cluster.get("registeredContainerInstancesCount", 0),
                  "running_tasks_count": cluster.get("runningTasksCount", 0),
                  "pending_tasks_count": cluster.get("pendingTasksCount", 0),
                  "active_services_count": cluster.get("activeServicesCount", 0),
                  "capacity_providers_count": len(capacity_providers),
                  "capacity_providers": capacity_providers,
                  "default_capacity_provider_strategy_count": len(default_strategy),
                  "default_capacity_provider_strategy": default_strategy,
                  "container_insights": settings.get("containerInsights"),
                  "attachments_count": len(cluster.get("attachments") or []),
                  "tags": tags_to_dict(cluster.get("tags") or []),
                  "statistics": {
                      "pending_tasks_count": get_stat(cluster, "pendingTasksCount"),
                      "running_tasks_count": get_stat(cluster, "runningTasksCount"),
                      "active_services_count": get_stat(cluster, "activeServicesCount"),
                      "draining_services_count": get_stat(cluster, "drainingServicesCount")
                  },
                  "review_flags": []
              }

              flags = []
              if item["status"] == "ACTIVE":
                  flags.append("active_cluster_review")
              if item["registered_container_instances_count"] > 0:
                  flags.append("registered_container_instances_review")
              if item["running_tasks_count"] > 0:
                  flags.append("running_tasks_review")
              if item["pending_tasks_count"] > 0:
                  flags.append("pending_tasks_review")
              if item["active_services_count"] > 0:
                  flags.append("active_services_review")
              if "FARGATE" in capacity_providers or "FARGATE_SPOT" in capacity_providers:
                  flags.append("fargate_capacity_provider_review")
              if item["container_insights"] == "enabled":
                  flags.append("container_insights_enabled_review")
              item["review_flags"] = sorted(set(flags))
              return item

          def lambda_handler(event, context):
              event = event if isinstance(event, dict) else {}
              excluded = csv_set(event.get("excluded_regions_csv", os.environ.get("EXCLUDED_REGIONS_CSV", "ap-northeast-3")))
              include_cluster_details = as_bool(event.get("include_cluster_details", os.environ.get("INCLUDE_CLUSTER_DETAILS", "true")), True)
              include_service_details = as_bool(event.get("include_service_details", os.environ.get("INCLUDE_SERVICE_DETAILS", "true")), True)
              include_task_samples = as_bool(event.get("include_task_samples", os.environ.get("INCLUDE_TASK_SAMPLES", "true")), True)
              include_tags = as_bool(event.get("include_tags", os.environ.get("INCLUDE_TAGS", "true")), True)
              max_clusters = as_int(event.get("max_clusters_per_region", os.environ.get("MAX_CLUSTERS_PER_REGION", "100")), 100)
              max_services = as_int(event.get("max_services_per_cluster", os.environ.get("MAX_SERVICES_PER_CLUSTER", "200")), 200)
              max_tasks = as_int(event.get("max_tasks_per_cluster", os.environ.get("MAX_TASKS_PER_CLUSTER", "100")), 100)

              result = {
                  "module": "amazon_ecs_cluster_service_cost_review",
                  "review_only": True,
                  "started_at": now(),
                  "ended_at": None,
                  "excluded_regions": sorted(excluded),
                  "totals": {
                      "regions_scanned": 0,
                      "regions_skipped": 0,
                      "clusters": 0,
                      "active_clusters": 0,
                      "clusters_with_container_instances": 0,
                      "registered_container_instances": 0,
                      "services": 0,
                      "active_services": 0,
                      "desired_tasks": 0,
                      "running_tasks": 0,
                      "pending_tasks": 0,
                      "sampled_tasks": 0,
                      "fargate_services": 0,
                      "ec2_services": 0,
                      "services_with_capacity_provider_strategy": 0,
                      "container_insights_enabled_clusters": 0,
                      "regions_with_ecs_resources": 0,
                      "regions_with_review_flags": 0,
                      "truncated_regions": 0,
                      "access_denied": 0,
                      "failed": 0
                  },
                  "regions": [],
                  "clusters": [],
                  "services": [],
                  "tasks": []
              }

              try:
                  regions = enabled_regions()
              except Exception as e:
                  result["fatal_error"] = str(e)
                  result["traceback"] = traceback.format_exc()
                  result["ended_at"] = now()
                  return result

              for region in regions:
                  if region in excluded:
                      result["totals"]["regions_skipped"] += 1
                      result["regions"].append({"region": region, "skipped": True, "reason": "excluded"})
                      continue

                  rec = {
                      "region": region,
                      "skipped": False,
                      "clusters": 0,
                      "active_clusters": 0,
                      "registered_container_instances": 0,
                      "services": 0,
                      "active_services": 0,
                      "desired_tasks": 0,
                      "running_tasks": 0,
                      "pending_tasks": 0,
                      "sampled_tasks": 0,
                      "fargate_services": 0,
                      "ec2_services": 0,
                      "services_with_capacity_provider_strategy": 0,
                      "container_insights_enabled_clusters": 0,
                      "review_flags": [],
                      "truncated": False,
                      "error": None
                  }

                  try:
                      client = boto3.client("ecs", region_name=region)
                      cluster_arns, clusters_truncated = list_with_token(client.list_clusters, "clusterArns", max_clusters)
                      rec["truncated"] = clusters_truncated

                      clusters = describe_clusters(client, cluster_arns, include_tags) if cluster_arns else []
                      cluster_details = []
                      service_details = []
                      task_details = []

                      for cluster in clusters:
                          cd = summarize_cluster(region, cluster)
                          cluster_details.append(cd)

                          rec["clusters"] += 1
                          result["totals"]["clusters"] += 1

                          if cd["status"] == "ACTIVE":
                              rec["active_clusters"] += 1
                              result["totals"]["active_clusters"] += 1

                          rec["registered_container_instances"] += cd["registered_container_instances_count"]
                          result["totals"]["registered_container_instances"] += cd["registered_container_instances_count"]

                          if cd["registered_container_instances_count"] > 0:
                              result["totals"]["clusters_with_container_instances"] += 1

                          if "container_insights_enabled_review" in cd["review_flags"]:
                              rec["container_insights_enabled_clusters"] += 1
                              result["totals"]["container_insights_enabled_clusters"] += 1

                          rec["review_flags"].extend(cd["review_flags"])

                          cluster_arn = cd["cluster_arn"]
                          service_arns, services_truncated = list_with_token(
                              client.list_services,
                              "serviceArns",
                              max_services,
                              cluster=cluster_arn
                          )
                          if services_truncated:
                              rec["truncated"] = True

                          services = describe_services(client, cluster_arn, service_arns, include_tags) if service_arns else []
                          for svc in services:
                              sd = summarize_service(region, cluster_arn, svc)
                              service_details.append(sd)

                              rec["services"] += 1
                              result["totals"]["services"] += 1

                              if sd["status"] == "ACTIVE":
                                  rec["active_services"] += 1
                                  result["totals"]["active_services"] += 1

                              rec["desired_tasks"] += sd["desired_count"]
                              rec["running_tasks"] += sd["running_count"]
                              rec["pending_tasks"] += sd["pending_count"]
                              result["totals"]["desired_tasks"] += sd["desired_count"]
                              result["totals"]["running_tasks"] += sd["running_count"]
                              result["totals"]["pending_tasks"] += sd["pending_count"]

                              if sd["launch_type"] == "FARGATE":
                                  rec["fargate_services"] += 1
                                  result["totals"]["fargate_services"] += 1
                              if sd["launch_type"] == "EC2":
                                  rec["ec2_services"] += 1
                                  result["totals"]["ec2_services"] += 1
                              if sd["capacity_provider_strategy_count"] > 0:
                                  rec["services_with_capacity_provider_strategy"] += 1
                                  result["totals"]["services_with_capacity_provider_strategy"] += 1

                              rec["review_flags"].extend(sd["review_flags"])

                          if include_task_samples:
                              task_arns, tasks_truncated = list_with_token(
                                  client.list_tasks,
                                  "taskArns",
                                  max_tasks,
                                  cluster=cluster_arn
                              )
                              if tasks_truncated:
                                  rec["truncated"] = True

                              tasks = describe_tasks(client, cluster_arn, task_arns, include_tags) if task_arns else []
                              for task in tasks:
                                  td = summarize_task(region, task)
                                  task_details.append(td)
                                  rec["sampled_tasks"] += 1
                                  result["totals"]["sampled_tasks"] += 1

                      if rec["truncated"]:
                          result["totals"]["truncated_regions"] += 1

                      rec["review_flags"] = sorted(set(rec["review_flags"]))

                      if rec["clusters"] > 0 or rec["services"] > 0 or rec["sampled_tasks"] > 0:
                          result["totals"]["regions_with_ecs_resources"] += 1
                      if rec["review_flags"]:
                          result["totals"]["regions_with_review_flags"] += 1

                      if include_cluster_details:
                          result["clusters"].extend(cluster_details)
                      if include_service_details:
                          result["services"].extend(service_details)
                      if include_task_samples:
                          result["tasks"].extend(task_details)

                      result["totals"]["regions_scanned"] += 1
                      result["regions"].append(rec)

                  except ClientError as e:
                      code = e.response.get("Error", {}).get("Code", "ClientError")
                      rec["error"] = code
                      if code in ("AccessDenied", "AccessDeniedException", "UnauthorizedOperation"):
                          result["totals"]["access_denied"] += 1
                      else:
                          result["totals"]["failed"] += 1
                      result["regions"].append(rec)

                  except EndpointConnectionError:
                      rec["error"] = "EndpointConnectionError"
                      result["totals"]["failed"] += 1
                      result["regions"].append(rec)

                  except Exception as e:
                      rec["error"] = str(e)
                      rec["traceback"] = traceback.format_exc()
                      result["totals"]["failed"] += 1
                      result["regions"].append(rec)

              result["ended_at"] = now()
              return result

  EcsCostReviewSchedule:
    Type: AWS::Events::Rule
    Condition: CreateSchedule
    Properties:
      Description: Scheduled review-only Amazon ECS cluster and service cost review.
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt EcsCostReviewFunction.Arn
          Id: EcsCostReviewFunctionTarget
          Input: '{"scheduled":true}'

  EcsCostReviewSchedulePermission:
    Type: AWS::Lambda::Permission
    Condition: CreateSchedule
    Properties:
      FunctionName: !Ref EcsCostReviewFunction
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt EcsCostReviewSchedule.Arn

Outputs:
  ReviewFunctionName:
    Description: Lambda function name.
    Value: !Ref EcsCostReviewFunction
  ReviewFunctionArn:
    Description: Lambda function ARN.
    Value: !GetAtt EcsCostReviewFunction.Arn
  ReviewRoleArn:
    Description: IAM role ARN used by the review Lambda.
    Value: !GetAtt EcsCostReviewRole.Arn
