AWSTemplateFormatVersion: '2010-09-09'
Description: >
  Elastic Beanstalk Cost Review Remediator.
  Review-only inventory of Elastic Beanstalk applications, application versions,
  environments, and linked resources across enabled Regions.

Parameters:
  ProjectName:
    Type: String
    Default: elastic-beanstalk-cost-review
  IncludeDetails:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
  MaxApplicationVersionsPerApplication:
    Type: Number
    Default: 100
    MinValue: 1
    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: ElasticBeanstalkCostReviewReadOnlyPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: ec2:DescribeRegions
                Resource: '*'
              - Effect: Allow
                Action:
                  - elasticbeanstalk:DescribeApplications
                  - elasticbeanstalk:DescribeApplicationVersions
                  - elasticbeanstalk:DescribeEnvironments
                  - elasticbeanstalk:DescribeEnvironmentResources
                  - elasticbeanstalk:DescribeConfigurationSettings
                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_APP_VERSIONS_PER_APP: !Ref MaxApplicationVersionsPerApplication
          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_applications(client):
              out, token = [], None
              while True:
                  kw = {}
                  if token:
                      kw["NextToken"] = token
                  resp = client.describe_applications(**kw)
                  out.extend(resp.get("Applications", []) or [])
                  token = resp.get("NextToken")
                  if not token:
                      return out

          def list_environments(client):
              out, token = [], None
              while True:
                  kw = {"IncludeDeleted": False}
                  if token:
                      kw["NextToken"] = token
                  resp = client.describe_environments(**kw)
                  out.extend(resp.get("Environments", []) or [])
                  token = resp.get("NextToken")
                  if not token:
                      return out

          def list_versions_for_app(client, app_name, max_items):
              out, token = [], None
              while True:
                  kw = {"ApplicationName": app_name, "MaxRecords": min(1000, max_items)}
                  if token:
                      kw["NextToken"] = token
                  resp = client.describe_application_versions(**kw)
                  out.extend(resp.get("ApplicationVersions", []) or [])
                  if len(out) >= max_items:
                      return out[:max_items]
                  token = resp.get("NextToken")
                  if not token:
                      return out

          def scan(region, details, max_versions):
              rr = {
                  "region": region,
                  "eb_applications": 0,
                  "eb_application_versions_sampled": 0,
                  "eb_environments": 0,
                  "eb_ready_environments": 0,
                  "eb_launching_environments": 0,
                  "eb_updating_environments": 0,
                  "eb_terminating_environments": 0,
                  "eb_terminated_environments": 0,
                  "eb_green_health_environments": 0,
                  "eb_yellow_health_environments": 0,
                  "eb_red_health_environments": 0,
                  "eb_gray_health_environments": 0,
                  "eb_webserver_environments": 0,
                  "eb_worker_environments": 0,
                  "eb_environments_with_cname": 0,
                  "eb_environments_with_endpoint_url": 0,
                  "eb_autoscaling_groups": 0,
                  "eb_instances": 0,
                  "eb_load_balancers": 0,
                  "eb_queues": 0,
                  "review_flags": [],
                  "application_inventory": [],
                  "application_version_inventory": [],
                  "environment_inventory": [],
                  "environment_resource_inventory": [],
                  "configuration_summary": [],
                  "access_denied": 0,
                  "failed": 0,
                  "failures": []
              }

              eb = boto3.client("elasticbeanstalk", region_name=region)

              apps = safe(rr, "ElasticBeanstalk", "DescribeApplications", lambda: list_applications(eb), [])
              rr["eb_applications"] = len(apps)

              for app in apps:
                  app_name = app.get("ApplicationName")
                  if details:
                      rr["application_inventory"].append({
                          "application_name": app_name,
                          "date_created": app.get("DateCreated"),
                          "date_updated": app.get("DateUpdated"),
                          "configuration_templates": app.get("ConfigurationTemplates") or [],
                          "resource_lifecycle_config_present": bool(app.get("ResourceLifecycleConfig"))
                      })

                  versions = safe(rr, "ElasticBeanstalk", "DescribeApplicationVersions", lambda n=app_name: list_versions_for_app(eb, n, max_versions), [])
                  rr["eb_application_versions_sampled"] += len(versions)
                  if details:
                      for v in versions:
                          rr["application_version_inventory"].append({
                              "application_name": app_name,
                              "version_label": v.get("VersionLabel"),
                              "status": v.get("Status"),
                              "date_created": v.get("DateCreated"),
                              "date_updated": v.get("DateUpdated"),
                              "source_bundle_bucket": (v.get("SourceBundle") or {}).get("S3Bucket"),
                              "source_bundle_key": (v.get("SourceBundle") or {}).get("S3Key")
                          })

              envs = safe(rr, "ElasticBeanstalk", "DescribeEnvironments", lambda: list_environments(eb), [])
              rr["eb_environments"] = len(envs)

              for env in envs:
                  env_name = env.get("EnvironmentName")
                  env_id = env.get("EnvironmentId")
                  status = env.get("Status")
                  health = env.get("Health")
                  tier = env.get("Tier") or {}

                  if status == "Ready":
                      rr["eb_ready_environments"] += 1
                  elif status == "Launching":
                      rr["eb_launching_environments"] += 1
                  elif status == "Updating":
                      rr["eb_updating_environments"] += 1
                  elif status == "Terminating":
                      rr["eb_terminating_environments"] += 1
                  elif status == "Terminated":
                      rr["eb_terminated_environments"] += 1

                  if health == "Green":
                      rr["eb_green_health_environments"] += 1
                  elif health == "Yellow":
                      rr["eb_yellow_health_environments"] += 1
                  elif health == "Red":
                      rr["eb_red_health_environments"] += 1
                  elif health == "Grey":
                      rr["eb_gray_health_environments"] += 1

                  if tier.get("Name") == "WebServer":
                      rr["eb_webserver_environments"] += 1
                  elif tier.get("Name") == "Worker":
                      rr["eb_worker_environments"] += 1

                  if env.get("CNAME"):
                      rr["eb_environments_with_cname"] += 1
                  if env.get("EndpointURL"):
                      rr["eb_environments_with_endpoint_url"] += 1

                  resources = safe(rr, "ElasticBeanstalk", "DescribeEnvironmentResources", lambda eid=env_id: eb.describe_environment_resources(EnvironmentId=eid).get("EnvironmentResources", {}), {})
                  asgs = resources.get("AutoScalingGroups") or []
                  instances = resources.get("Instances") or []
                  lbs = resources.get("LoadBalancers") or []
                  queues = resources.get("Queues") or []
                  rr["eb_autoscaling_groups"] += len(asgs)
                  rr["eb_instances"] += len(instances)
                  rr["eb_load_balancers"] += len(lbs)
                  rr["eb_queues"] += len(queues)

                  config = safe(rr, "ElasticBeanstalk", "DescribeConfigurationSettings", lambda app=env.get("ApplicationName"), e=env_name: eb.describe_configuration_settings(ApplicationName=app, EnvironmentName=e).get("ConfigurationSettings", []), [])
                  if details:
                      rr["environment_inventory"].append({
                          "application_name": env.get("ApplicationName"),
                          "environment_name": env_name,
                          "environment_id": env_id,
                          "arn": env.get("EnvironmentArn"),
                          "version_label": env.get("VersionLabel"),
                          "status": status,
                          "health": health,
                          "health_status": env.get("HealthStatus"),
                          "tier_name": tier.get("Name"),
                          "tier_type": tier.get("Type"),
                          "platform_arn": env.get("PlatformArn"),
                          "solution_stack_name": env.get("SolutionStackName"),
                          "cname": env.get("CNAME"),
                          "endpoint_url": env.get("EndpointURL"),
                          "date_created": env.get("DateCreated"),
                          "date_updated": env.get("DateUpdated")
                      })
                      rr["environment_resource_inventory"].append({
                          "application_name": env.get("ApplicationName"),
                          "environment_name": env_name,
                          "environment_id": env_id,
                          "autoscaling_groups": [x.get("Name") for x in asgs],
                          "instances": [x.get("Id") for x in instances],
                          "load_balancers": [x.get("Name") for x in lbs],
                          "queues": [x.get("URL") for x in queues]
                      })
                      rr["configuration_summary"].append({
                          "application_name": env.get("ApplicationName"),
                          "environment_name": env_name,
                          "environment_id": env_id,
                          "configuration_settings_count": len(config),
                          "option_settings_count": sum(len(c.get("OptionSettings") or []) for c in config)
                      })

              if rr["eb_environments"] > 0:
                  rr["review_flags"].append("elastic_beanstalk_environments_present")
              if rr["eb_instances"] > 0:
                  rr["review_flags"].append("elastic_beanstalk_instances_present")
              if rr["eb_load_balancers"] > 0:
                  rr["review_flags"].append("elastic_beanstalk_load_balancers_present")
              if rr["eb_red_health_environments"] > 0 or rr["eb_yellow_health_environments"] > 0:
                  rr["review_flags"].append("elastic_beanstalk_non_green_health_environments_present")
              if rr["eb_application_versions_sampled"] > 0:
                  rr["review_flags"].append("elastic_beanstalk_application_versions_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_versions = int(event.get("max_application_versions_per_application") or os.environ.get("MAX_APP_VERSIONS_PER_APP", "100"))

              total_keys = [
                  "regions_scanned", "eb_applications", "eb_application_versions_sampled",
                  "eb_environments", "eb_ready_environments", "eb_launching_environments",
                  "eb_updating_environments", "eb_terminating_environments", "eb_terminated_environments",
                  "eb_green_health_environments", "eb_yellow_health_environments",
                  "eb_red_health_environments", "eb_gray_health_environments",
                  "eb_webserver_environments", "eb_worker_environments",
                  "eb_environments_with_cname", "eb_environments_with_endpoint_url",
                  "eb_autoscaling_groups", "eb_instances", "eb_load_balancers", "eb_queues",
                  "regions_with_applications", "regions_with_environments", "regions_with_instances",
                  "regions_with_load_balancers", "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_versions)
                  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["eb_applications"] > 0:
                      totals["regions_with_applications"] += 1
                  if rr["eb_environments"] > 0:
                      totals["regions_with_environments"] += 1
                  if rr["eb_instances"] > 0:
                      totals["regions_with_instances"] += 1
                  if rr["eb_load_balancers"] > 0:
                      totals["regions_with_load_balancers"] += 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: ElasticBeanstalkCostReviewTarget
          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'
