AWSTemplateFormatVersion: '2010-09-09'
Description: >
  ECS / Fargate / App Runner Cost Review Remediator.
  Review-only inventory of ECS clusters, services, running tasks, task definition usage,
  capacity providers, and App Runner services across enabled Regions.

Parameters:
  ProjectName:
    Type: String
    Default: ecs-apprunner-cost-review
  IncludeDetails:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
  MaxTasksPerCluster:
    Type: Number
    Default: 100
    MinValue: 0
    MaxValue: 1000
  ExcludedRegionsCsv:
    Type: String
    Default: ap-northeast-3
  ScheduleEnabled:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
  ScheduleExpression:
    Type: String
    Default: rate(30 days)

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

Resources:
  ReviewRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal: { Service: lambda.amazonaws.com }
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: EcsAppRunnerCostReviewReadOnlyPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: ec2:DescribeRegions
                Resource: '*'
              - Effect: Allow
                Action:
                  - ecs:ListClusters
                  - ecs:DescribeClusters
                  - ecs:ListServices
                  - ecs:DescribeServices
                  - ecs:ListTasks
                  - ecs:DescribeTasks
                  - ecs:ListTaskDefinitions
                  - ecs:DescribeTaskDefinition
                  - ecs:DescribeCapacityProviders
                  - apprunner:ListServices
                  - apprunner:DescribeService
                Resource: '*'

  ReviewFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref ProjectName
      Runtime: python3.12
      Handler: index.handler
      Timeout: 900
      MemorySize: 512
      Role: !GetAtt ReviewRole.Arn
      Environment:
        Variables:
          INCLUDE_DETAILS: !Ref IncludeDetails
          MAX_TASKS_PER_CLUSTER: !Ref MaxTasksPerCluster
          EXCLUDED_REGIONS_CSV: !Ref ExcludedRegionsCsv
      Code:
        ZipFile: |
          import json, os
          from datetime import datetime, timezone
          import boto3
          from botocore.exceptions import ClientError, EndpointConnectionError

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

          def boolish(v):
              return str(v).strip().lower() in ("1", "true", "yes", "y")

          def csv(v):
              return [x.strip() for x in (v or "").split(",") if x.strip()]

          def err(service, op, e):
              if isinstance(e, ClientError):
                  return {
                      "service": service,
                      "operation": op,
                      "error_code": e.response.get("Error", {}).get("Code"),
                      "error_message": e.response.get("Error", {}).get("Message")
                  }
              return {"service": service, "operation": op, "error_code": type(e).__name__, "error_message": str(e)}

          def denied(e):
              s = str(e).lower()
              if isinstance(e, ClientError):
                  s = (e.response.get("Error", {}).get("Code", "") + " " + e.response.get("Error", {}).get("Message", "")).lower()
              return "accessdenied" in s or "not authorized" in s or "explicit deny" in s or "service control policy" in s

          def safe(rr, svc, op, fn, default):
              try:
                  return fn()
              except EndpointConnectionError as e:
                  rr["failed"] += 1
                  rr["failures"].append(err(svc, op, e))
                  return default
              except ClientError as e:
                  if denied(e):
                      rr["access_denied"] += 1
                  else:
                      rr["failed"] += 1
                  rr["failures"].append(err(svc, op, e))
                  return default
              except Exception as e:
                  rr["failed"] += 1
                  rr["failures"].append(err(svc, op, e))
                  return default

          def safe_optional_endpoint(rr, svc, op, fn, default):
              try:
                  return fn()
              except EndpointConnectionError:
                  return default
              except ClientError as e:
                  msg = (e.response.get("Error", {}).get("Code", "") + " " + e.response.get("Error", {}).get("Message", "")).lower()
                  if "could not connect" in msg or "unknownendpoint" in msg:
                      return default
                  if denied(e):
                      rr["access_denied"] += 1
                  else:
                      rr["failed"] += 1
                  rr["failures"].append(err(svc, op, e))
                  return default
              except Exception as e:
                  rr["failed"] += 1
                  rr["failures"].append(err(svc, op, e))
                  return default

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

          def list_all(client_call, key, token_name="nextToken", out_token_name="nextToken", **base):
              out, token = [], None
              while True:
                  kw = dict(base)
                  if token:
                      kw[token_name] = token
                  resp = client_call(**kw)
                  out.extend(resp.get(key, []) or [])
                  token = resp.get(out_token_name)
                  if not token:
                      return out

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

          def scan(region, details, max_tasks):
              rr = {
                  "region": region,
                  "ecs_clusters": 0,
                  "ecs_active_clusters": 0,
                  "ecs_registered_container_instances": 0,
                  "ecs_running_tasks_cluster_metric": 0,
                  "ecs_pending_tasks_cluster_metric": 0,
                  "ecs_active_services_cluster_metric": 0,
                  "ecs_services": 0,
                  "ecs_active_services": 0,
                  "ecs_running_services": 0,
                  "ecs_desired_tasks": 0,
                  "ecs_running_service_tasks": 0,
                  "ecs_pending_service_tasks": 0,
                  "ecs_fargate_services": 0,
                  "ecs_ec2_services": 0,
                  "ecs_capacity_provider_services": 0,
                  "ecs_services_with_zero_desired": 0,
                  "ecs_services_with_running_tasks": 0,
                  "ecs_standalone_tasks_sampled": 0,
                  "ecs_running_standalone_tasks_sampled": 0,
                  "ecs_pending_standalone_tasks_sampled": 0,
                  "ecs_task_definitions_active": 0,
                  "ecs_capacity_providers": 0,
                  "ecs_capacity_providers_active": 0,
                  "app_runner_services": 0,
                  "app_runner_running_services": 0,
                  "app_runner_paused_services": 0,
                  "app_runner_failed_operations": 0,
                  "review_flags": [],
                  "ecs_cluster_inventory": [],
                  "ecs_service_inventory": [],
                  "app_runner_inventory": [],
                  "access_denied": 0,
                  "failed": 0,
                  "failures": []
              }

              ecs = boto3.client("ecs", region_name=region)
              clusters = safe(rr, "ECS", "ListClusters", lambda: list_all(ecs.list_clusters, "clusterArns"), [])
              rr["ecs_clusters"] = len(clusters)

              if clusters:
                  for group in chunks(clusters, 100):
                      desc = safe(rr, "ECS", "DescribeClusters", lambda g=group: ecs.describe_clusters(clusters=g, include=["STATISTICS","TAGS"]).get("clusters", []), [])
                      for c in desc:
                          if c.get("status") == "ACTIVE":
                              rr["ecs_active_clusters"] += 1
                          rr["ecs_registered_container_instances"] += int(c.get("registeredContainerInstancesCount") or 0)
                          rr["ecs_running_tasks_cluster_metric"] += int(c.get("runningTasksCount") or 0)
                          rr["ecs_pending_tasks_cluster_metric"] += int(c.get("pendingTasksCount") or 0)
                          rr["ecs_active_services_cluster_metric"] += int(c.get("activeServicesCount") or 0)
                          if details:
                              rr["ecs_cluster_inventory"].append({
                                  "cluster_name": c.get("clusterName"),
                                  "cluster_arn": c.get("clusterArn"),
                                  "status": c.get("status"),
                                  "registered_container_instances": c.get("registeredContainerInstancesCount"),
                                  "running_tasks": c.get("runningTasksCount"),
                                  "pending_tasks": c.get("pendingTasksCount"),
                                  "active_services": c.get("activeServicesCount")
                              })

              for cluster_arn in clusters:
                  services = safe(rr, "ECS", "ListServices", lambda ca=cluster_arn: list_all(ecs.list_services, "serviceArns", cluster=ca), [])
                  rr["ecs_services"] += len(services)
                  for svc_group in chunks(services, 10):
                      svc_desc = safe(rr, "ECS", "DescribeServices", lambda ca=cluster_arn, g=svc_group: ecs.describe_services(cluster=ca, services=g, include=["TAGS"]).get("services", []), [])
                      for s in svc_desc:
                          status = s.get("status")
                          launch = s.get("launchType")
                          desired = int(s.get("desiredCount") or 0)
                          running = int(s.get("runningCount") or 0)
                          pending = int(s.get("pendingCount") or 0)
                          if status == "ACTIVE":
                              rr["ecs_active_services"] += 1
                          if running > 0:
                              rr["ecs_running_services"] += 1
                              rr["ecs_services_with_running_tasks"] += 1
                          if desired == 0:
                              rr["ecs_services_with_zero_desired"] += 1
                          rr["ecs_desired_tasks"] += desired
                          rr["ecs_running_service_tasks"] += running
                          rr["ecs_pending_service_tasks"] += pending
                          if launch == "FARGATE":
                              rr["ecs_fargate_services"] += 1
                          elif launch == "EC2":
                              rr["ecs_ec2_services"] += 1
                          if s.get("capacityProviderStrategy"):
                              rr["ecs_capacity_provider_services"] += 1
                          if details:
                              rr["ecs_service_inventory"].append({
                                  "cluster_arn": cluster_arn,
                                  "service_name": s.get("serviceName"),
                                  "service_arn": s.get("serviceArn"),
                                  "status": status,
                                  "launch_type": launch,
                                  "scheduling_strategy": s.get("schedulingStrategy"),
                                  "desired_count": desired,
                                  "running_count": running,
                                  "pending_count": pending,
                                  "capacity_provider_strategy": s.get("capacityProviderStrategy", []),
                                  "task_definition": s.get("taskDefinition"),
                                  "created_at": s.get("createdAt")
                              })

                  tasks = safe(rr, "ECS", "ListTasks", lambda ca=cluster_arn: list_all(ecs.list_tasks, "taskArns", cluster=ca)[:max_tasks], [])
                  if tasks:
                      task_desc = []
                      for task_group in chunks(tasks, 100):
                          task_desc += safe(rr, "ECS", "DescribeTasks", lambda ca=cluster_arn, g=task_group: ecs.describe_tasks(cluster=ca, tasks=g).get("tasks", []), [])
                      for t in task_desc:
                          group = t.get("group", "")
                          if not group.startswith("service:"):
                              rr["ecs_standalone_tasks_sampled"] += 1
                              if t.get("lastStatus") == "RUNNING":
                                  rr["ecs_running_standalone_tasks_sampled"] += 1
                              elif t.get("lastStatus") == "PENDING":
                                  rr["ecs_pending_standalone_tasks_sampled"] += 1

              task_defs = safe(rr, "ECS", "ListTaskDefinitions", lambda: list_all(ecs.list_task_definitions, "taskDefinitionArns", status="ACTIVE"), [])
              rr["ecs_task_definitions_active"] = len(task_defs)

              cp_desc = safe(rr, "ECS", "DescribeCapacityProviders", lambda: ecs.describe_capacity_providers().get("capacityProviders", []), [])
              rr["ecs_capacity_providers"] = len(cp_desc)
              rr["ecs_capacity_providers_active"] = len([c for c in cp_desc if c.get("status") == "ACTIVE"])

              ar = boto3.client("apprunner", region_name=region)
              ar_services = safe_optional_endpoint(rr, "AppRunner", "ListServices", lambda: list_all(ar.list_services, "ServiceSummaryList", token_name="NextToken", out_token_name="NextToken"), [])
              rr["app_runner_services"] = len(ar_services)
              for svc in ar_services:
                  arn = svc.get("ServiceArn")
                  desc = safe_optional_endpoint(rr, "AppRunner", "DescribeService", lambda a=arn: ar.describe_service(ServiceArn=a).get("Service", {}), {})
                  status = desc.get("Status") or svc.get("Status")
                  if status == "RUNNING":
                      rr["app_runner_running_services"] += 1
                  elif status == "PAUSED":
                      rr["app_runner_paused_services"] += 1
                  elif status in ("OPERATION_IN_PROGRESS", "CREATE_FAILED", "DELETE_FAILED"):
                      rr["app_runner_failed_operations"] += 1
                  if details:
                      rr["app_runner_inventory"].append({
                          "service_name": desc.get("ServiceName") or svc.get("ServiceName"),
                          "service_arn": arn,
                          "status": status,
                          "created_at": desc.get("CreatedAt") or svc.get("CreatedAt"),
                          "updated_at": desc.get("UpdatedAt"),
                          "cpu": ((desc.get("InstanceConfiguration") or {}).get("Cpu")),
                          "memory": ((desc.get("InstanceConfiguration") or {}).get("Memory")),
                          "auto_scaling_configuration_arn": desc.get("AutoScalingConfigurationSummary", {}).get("AutoScalingConfigurationArn")
                      })

              if rr["ecs_running_service_tasks"] > 0:
                  rr["review_flags"].append("ecs_services_with_running_tasks")
              if rr["ecs_running_standalone_tasks_sampled"] > 0:
                  rr["review_flags"].append("ecs_standalone_running_tasks_sampled")
              if rr["ecs_registered_container_instances"] > 0:
                  rr["review_flags"].append("ecs_registered_container_instances_present")
              if rr["ecs_fargate_services"] > 0:
                  rr["review_flags"].append("ecs_fargate_services_present")
              if rr["ecs_ec2_services"] > 0:
                  rr["review_flags"].append("ecs_ec2_services_present")
              if rr["app_runner_running_services"] > 0:
                  rr["review_flags"].append("app_runner_running_services_present")
              if rr["app_runner_failed_operations"] > 0:
                  rr["review_flags"].append("app_runner_failed_or_in_progress_operations_present")

              return rr

          def handler(event, context):
              event = event or {}
              excluded = set(csv(event.get("excluded_regions_csv") or os.environ.get("EXCLUDED_REGIONS_CSV", "ap-northeast-3")))
              details = boolish(event.get("include_details", os.environ.get("INCLUDE_DETAILS", "true")))
              max_tasks = int(event.get("max_tasks_per_cluster") or os.environ.get("MAX_TASKS_PER_CLUSTER", "100"))

              total_keys = [
                  "regions_scanned", "ecs_clusters", "ecs_active_clusters", "ecs_registered_container_instances",
                  "ecs_running_tasks_cluster_metric", "ecs_pending_tasks_cluster_metric", "ecs_active_services_cluster_metric",
                  "ecs_services", "ecs_active_services", "ecs_running_services", "ecs_desired_tasks",
                  "ecs_running_service_tasks", "ecs_pending_service_tasks", "ecs_fargate_services", "ecs_ec2_services",
                  "ecs_capacity_provider_services", "ecs_services_with_zero_desired", "ecs_services_with_running_tasks",
                  "ecs_standalone_tasks_sampled", "ecs_running_standalone_tasks_sampled",
                  "ecs_pending_standalone_tasks_sampled", "ecs_task_definitions_active", "ecs_capacity_providers",
                  "ecs_capacity_providers_active", "app_runner_services", "app_runner_running_services",
                  "app_runner_paused_services", "app_runner_failed_operations", "regions_with_ecs_clusters",
                  "regions_with_ecs_services", "regions_with_app_runner_services", "regions_with_review_flags",
                  "access_denied", "failed"
              ]
              totals = {k: 0 for k in total_keys}

              output = {
                  "started_at": now(),
                  "review_only": True,
                  "include_details": details,
                  "excluded_regions": sorted(excluded),
                  "totals": totals,
                  "region_results": [],
                  "failures": []
              }

              for region in enabled_regions(excluded):
                  rr = scan(region, details, max_tasks)
                  for k in total_keys:
                      if k.startswith("regions_with_") or k == "regions_scanned":
                          continue
                      totals[k] += int(rr.get(k, 0) or 0)
                  totals["regions_scanned"] += 1
                  if rr["ecs_clusters"] > 0:
                      totals["regions_with_ecs_clusters"] += 1
                  if rr["ecs_services"] > 0:
                      totals["regions_with_ecs_services"] += 1
                  if rr["app_runner_services"] > 0:
                      totals["regions_with_app_runner_services"] += 1
                  if rr["review_flags"]:
                      totals["regions_with_review_flags"] += 1
                  output["failures"].extend([dict(f, region=region) for f in rr.get("failures", [])])
                  output["region_results"].append(rr)

              output["ended_at"] = now()
              print(json.dumps(output, indent=2, default=str))
              return json.loads(json.dumps(output, default=str))

  ReviewScheduleRule:
    Type: AWS::Events::Rule
    Condition: IsScheduleEnabled
    Properties:
      Name: !Sub '${ProjectName}-schedule'
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt ReviewFunction.Arn
          Id: EcsAppRunnerCostReviewTarget
          Input: '{"source":"eventbridge-schedule"}'

  ReviewSchedulePermission:
    Type: AWS::Lambda::Permission
    Condition: IsScheduleEnabled
    Properties:
      FunctionName: !Ref ReviewFunction
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt ReviewScheduleRule.Arn

Outputs:
  ReviewFunctionName:
    Description: Lambda function name.
    Value: !Ref ReviewFunction
  RemediatorFunctionName:
    Description: Lambda function name alias.
    Value: !Ref ReviewFunction
  ReviewFunctionArn:
    Description: Lambda function ARN.
    Value: !GetAtt ReviewFunction.Arn
  ReviewOnly:
    Description: This module is intentionally review-only.
    Value: 'true'
