AWSTemplateFormatVersion: '2010-09-09'
Description: >
  EKS / Managed Node Group / Fargate Profile Cost Review Remediator.
  Review-only inventory of EKS clusters, managed node groups, Fargate profiles,
  and add-ons across enabled Regions.

Parameters:
  ProjectName:
    Type: String
    Default: eks-cost-review
  IncludeDetails:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
  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: EksCostReviewReadOnlyPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: ec2:DescribeRegions
                Resource: '*'
              - Effect: Allow
                Action:
                  - eks:ListClusters
                  - eks:DescribeCluster
                  - eks:ListNodegroups
                  - eks:DescribeNodegroup
                  - eks:ListFargateProfiles
                  - eks:DescribeFargateProfile
                  - eks:ListAddons
                  - eks:DescribeAddon
                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
          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 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(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 = call(**kw)
                  out.extend(resp.get(key, []) or [])
                  token = resp.get(out_token_name)
                  if not token:
                      return out

          def scan(region, details):
              rr = {
                  "region": region,
                  "eks_clusters": 0,
                  "eks_active_clusters": 0,
                  "eks_creating_clusters": 0,
                  "eks_deleting_clusters": 0,
                  "eks_failed_clusters": 0,
                  "eks_public_endpoint_clusters": 0,
                  "eks_private_endpoint_clusters": 0,
                  "eks_managed_nodegroups": 0,
                  "eks_active_nodegroups": 0,
                  "eks_degraded_nodegroups": 0,
                  "eks_nodegroup_desired_capacity": 0,
                  "eks_nodegroup_min_capacity": 0,
                  "eks_nodegroup_max_capacity": 0,
                  "eks_on_demand_nodegroups": 0,
                  "eks_spot_nodegroups": 0,
                  "eks_fargate_profiles": 0,
                  "eks_active_fargate_profiles": 0,
                  "eks_addons": 0,
                  "eks_active_addons": 0,
                  "eks_degraded_addons": 0,
                  "review_flags": [],
                  "cluster_inventory": [],
                  "nodegroup_inventory": [],
                  "fargate_profile_inventory": [],
                  "addon_inventory": [],
                  "access_denied": 0,
                  "failed": 0,
                  "failures": []
              }

              eks = boto3.client("eks", region_name=region)
              clusters = safe(rr, "EKS", "ListClusters", lambda: list_all(eks.list_clusters, "clusters"), [])
              rr["eks_clusters"] = len(clusters)

              for cluster_name in clusters:
                  cluster = safe(rr, "EKS", "DescribeCluster", lambda n=cluster_name: eks.describe_cluster(name=n).get("cluster", {}), {})
                  status = cluster.get("status")
                  resources = cluster.get("resourcesVpcConfig") or {}
                  if status == "ACTIVE":
                      rr["eks_active_clusters"] += 1
                  elif status == "CREATING":
                      rr["eks_creating_clusters"] += 1
                  elif status == "DELETING":
                      rr["eks_deleting_clusters"] += 1
                  elif status == "FAILED":
                      rr["eks_failed_clusters"] += 1
                  if resources.get("endpointPublicAccess"):
                      rr["eks_public_endpoint_clusters"] += 1
                  if resources.get("endpointPrivateAccess"):
                      rr["eks_private_endpoint_clusters"] += 1

                  if details:
                      rr["cluster_inventory"].append({
                          "name": cluster.get("name") or cluster_name,
                          "arn": cluster.get("arn"),
                          "status": status,
                          "version": cluster.get("version"),
                          "platform_version": cluster.get("platformVersion"),
                          "created_at": cluster.get("createdAt"),
                          "endpoint_public_access": resources.get("endpointPublicAccess"),
                          "endpoint_private_access": resources.get("endpointPrivateAccess"),
                          "subnet_count": len(resources.get("subnetIds") or []),
                          "security_group_count": len(resources.get("securityGroupIds") or [])
                      })

                  nodegroups = safe(rr, "EKS", "ListNodegroups", lambda n=cluster_name: list_all(eks.list_nodegroups, "nodegroups", clusterName=n), [])
                  rr["eks_managed_nodegroups"] += len(nodegroups)
                  for ng_name in nodegroups:
                      ng = safe(rr, "EKS", "DescribeNodegroup", lambda c=cluster_name, n=ng_name: eks.describe_nodegroup(clusterName=c, nodegroupName=n).get("nodegroup", {}), {})
                      ng_status = ng.get("status")
                      scaling = ng.get("scalingConfig") or {}
                      desired = int(scaling.get("desiredSize") or 0)
                      min_size = int(scaling.get("minSize") or 0)
                      max_size = int(scaling.get("maxSize") or 0)
                      rr["eks_nodegroup_desired_capacity"] += desired
                      rr["eks_nodegroup_min_capacity"] += min_size
                      rr["eks_nodegroup_max_capacity"] += max_size
                      if ng_status == "ACTIVE":
                          rr["eks_active_nodegroups"] += 1
                      elif ng_status == "DEGRADED":
                          rr["eks_degraded_nodegroups"] += 1
                      if ng.get("capacityType") == "SPOT":
                          rr["eks_spot_nodegroups"] += 1
                      elif ng.get("capacityType") == "ON_DEMAND":
                          rr["eks_on_demand_nodegroups"] += 1
                      if details:
                          rr["nodegroup_inventory"].append({
                              "cluster_name": cluster_name,
                              "nodegroup_name": ng.get("nodegroupName") or ng_name,
                              "nodegroup_arn": ng.get("nodegroupArn"),
                              "status": ng_status,
                              "capacity_type": ng.get("capacityType"),
                              "ami_type": ng.get("amiType"),
                              "instance_types": ng.get("instanceTypes") or [],
                              "desired_size": desired,
                              "min_size": min_size,
                              "max_size": max_size,
                              "disk_size": ng.get("diskSize"),
                              "created_at": ng.get("createdAt"),
                              "subnet_count": len(ng.get("subnets") or [])
                          })

                  profiles = safe(rr, "EKS", "ListFargateProfiles", lambda n=cluster_name: list_all(eks.list_fargate_profiles, "fargateProfileNames", clusterName=n), [])
                  rr["eks_fargate_profiles"] += len(profiles)
                  for fp_name in profiles:
                      fp = safe(rr, "EKS", "DescribeFargateProfile", lambda c=cluster_name, n=fp_name: eks.describe_fargate_profile(clusterName=c, fargateProfileName=n).get("fargateProfile", {}), {})
                      if fp.get("status") == "ACTIVE":
                          rr["eks_active_fargate_profiles"] += 1
                      if details:
                          rr["fargate_profile_inventory"].append({
                              "cluster_name": cluster_name,
                              "fargate_profile_name": fp.get("fargateProfileName") or fp_name,
                              "fargate_profile_arn": fp.get("fargateProfileArn"),
                              "status": fp.get("status"),
                              "subnet_count": len(fp.get("subnets") or []),
                              "selector_count": len(fp.get("selectors") or []),
                              "created_at": fp.get("createdAt")
                          })

                  addons = safe(rr, "EKS", "ListAddons", lambda n=cluster_name: list_all(eks.list_addons, "addons", clusterName=n), [])
                  rr["eks_addons"] += len(addons)
                  for addon_name in addons:
                      addon = safe(rr, "EKS", "DescribeAddon", lambda c=cluster_name, a=addon_name: eks.describe_addon(clusterName=c, addonName=a).get("addon", {}), {})
                      if addon.get("status") == "ACTIVE":
                          rr["eks_active_addons"] += 1
                      elif addon.get("status") == "DEGRADED":
                          rr["eks_degraded_addons"] += 1
                      if details:
                          rr["addon_inventory"].append({
                              "cluster_name": cluster_name,
                              "addon_name": addon.get("addonName") or addon_name,
                              "addon_arn": addon.get("addonArn"),
                              "status": addon.get("status"),
                              "addon_version": addon.get("addonVersion"),
                              "created_at": addon.get("createdAt"),
                              "modified_at": addon.get("modifiedAt")
                          })

              if rr["eks_active_clusters"] > 0:
                  rr["review_flags"].append("active_eks_clusters_present")
              if rr["eks_public_endpoint_clusters"] > 0:
                  rr["review_flags"].append("eks_public_endpoint_clusters_present")
              if rr["eks_nodegroup_desired_capacity"] > 0:
                  rr["review_flags"].append("eks_managed_nodegroup_capacity_present")
              if rr["eks_degraded_nodegroups"] > 0:
                  rr["review_flags"].append("eks_degraded_nodegroups_present")
              if rr["eks_fargate_profiles"] > 0:
                  rr["review_flags"].append("eks_fargate_profiles_present")
              if rr["eks_degraded_addons"] > 0:
                  rr["review_flags"].append("eks_degraded_addons_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")))

              total_keys = [
                  "regions_scanned", "eks_clusters", "eks_active_clusters", "eks_creating_clusters",
                  "eks_deleting_clusters", "eks_failed_clusters", "eks_public_endpoint_clusters",
                  "eks_private_endpoint_clusters", "eks_managed_nodegroups", "eks_active_nodegroups",
                  "eks_degraded_nodegroups", "eks_nodegroup_desired_capacity", "eks_nodegroup_min_capacity",
                  "eks_nodegroup_max_capacity", "eks_on_demand_nodegroups", "eks_spot_nodegroups",
                  "eks_fargate_profiles", "eks_active_fargate_profiles", "eks_addons", "eks_active_addons",
                  "eks_degraded_addons", "regions_with_eks_clusters", "regions_with_nodegroups",
                  "regions_with_fargate_profiles", "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)
                  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["eks_clusters"] > 0:
                      totals["regions_with_eks_clusters"] += 1
                  if rr["eks_managed_nodegroups"] > 0:
                      totals["regions_with_nodegroups"] += 1
                  if rr["eks_fargate_profiles"] > 0:
                      totals["regions_with_fargate_profiles"] += 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: EksCostReviewTarget
          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'
