AWSTemplateFormatVersion: '2010-09-09'
Description: >
  VPC Endpoint Cost Review Remediator.
  Review-only module that inventories VPC endpoints across enabled Regions,
  with emphasis on Interface endpoints that may create hourly per-AZ costs.
  This module intentionally does not delete or modify endpoints.

Parameters:
  ProjectName:
    Type: String
    Default: vpc-endpoint-cost-review
    Description: Short base name used for created resources.

  IncludeEndpointDetails:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Include endpoint detail inventory in Lambda response.

  IncludeTags:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Include tags in endpoint inventory.

  ExcludedRegionsCsv:
    Type: String
    Default: ap-northeast-3
    Description: Comma-separated Regions to skip.

  ScheduleEnabled:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
    Description: Whether to create an enabled EventBridge schedule.

  ScheduleExpression:
    Type: String
    Default: rate(30 days)
    Description: EventBridge schedule expression used when ScheduleEnabled is true.

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: VpcEndpointCostReviewPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: RegionDiscovery
                Effect: Allow
                Action:
                  - ec2:DescribeRegions
                Resource: '*'
              - Sid: VpcEndpointRead
                Effect: Allow
                Action:
                  - ec2:DescribeVpcEndpoints
                  - ec2:DescribeVpcs
                  - ec2:DescribeSubnets
                  - ec2:DescribeRouteTables
                  - ec2:DescribeSecurityGroups
                  - ec2:DescribeNetworkInterfaces
                Resource: '*'

  ReviewFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref ProjectName
      Description: Review-only Lambda for VPC endpoint cost visibility.
      Runtime: python3.12
      Handler: index.handler
      Timeout: 900
      MemorySize: 512
      Role: !GetAtt ReviewRole.Arn
      Environment:
        Variables:
          INCLUDE_ENDPOINT_DETAILS: !Ref IncludeEndpointDetails
          INCLUDE_TAGS: !Ref IncludeTags
          EXCLUDED_REGIONS_CSV: !Ref ExcludedRegionsCsv
      Code:
        ZipFile: |
          import json
          import os
          from datetime import datetime, timezone

          import boto3
          from botocore.exceptions import ClientError, EndpointConnectionError

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

          def env_bool(name, default=False):
              return os.environ.get(name, str(default)).strip().lower() in ("1", "true", "yes", "y")

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

          def csv_list(value):
              raw = value or ""
              return [x.strip() for x in raw.split(",") if x.strip()]

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

          def is_access_denied(err):
              text = str(err).lower()
              if isinstance(err, ClientError):
                  code = err.response.get("Error", {}).get("Code", "").lower()
                  msg = err.response.get("Error", {}).get("Message", "").lower()
                  text = f"{code} {msg}"
              return "accessdenied" in text or "not authorized" in text or "explicit deny" in text or "service control policy" in text

          def safe_call(region_result, service_name, operation_name, func, default):
              try:
                  return func()
              except EndpointConnectionError as e:
                  region_result["failed"] += 1
                  region_result["failures"].append(error_record(service_name, operation_name, e))
                  return default
              except ClientError as e:
                  if is_access_denied(e):
                      region_result["access_denied"] += 1
                  else:
                      region_result["failed"] += 1
                  region_result["failures"].append(error_record(service_name, operation_name, e))
                  return default
              except Exception as e:
                  region_result["failed"] += 1
                  region_result["failures"].append(error_record(service_name, operation_name, e))
                  return default

          def get_enabled_regions(excluded):
              ec2 = boto3.client("ec2", region_name="us-east-1")
              response = ec2.describe_regions(AllRegions=True)
              regions = []
              for r in response.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 paginate(client, operation, result_key, kwargs=None):
              kwargs = kwargs or {}
              paginator = client.get_paginator(operation)
              items = []
              for page in paginator.paginate(**kwargs):
                  items.extend(page.get(result_key, []) or [])
              return items

          def tags_to_dict(tags):
              out = {}
              for tag in tags or []:
                  key = tag.get("Key")
                  if key:
                      out[key] = tag.get("Value")
              return out

          def service_short_name(service_name):
              # Examples:
              # com.amazonaws.us-east-1.ssm -> ssm
              # com.amazonaws.vpce.us-east-1.vpce-svc-... -> vpce-svc-...
              parts = (service_name or "").split(".")
              return parts[-1] if parts else service_name

          def endpoint_inventory_item(endpoint, include_tags):
              endpoint_type = endpoint.get("VpcEndpointType")
              subnet_ids = endpoint.get("SubnetIds", []) or []
              route_table_ids = endpoint.get("RouteTableIds", []) or []
              groups = endpoint.get("Groups", []) or []
              dns_entries = endpoint.get("DnsEntries", []) or []
              network_interface_ids = endpoint.get("NetworkInterfaceIds", []) or []

              item = {
                  "vpc_endpoint_id": endpoint.get("VpcEndpointId"),
                  "vpc_id": endpoint.get("VpcId"),
                  "service_name": endpoint.get("ServiceName"),
                  "service_short_name": service_short_name(endpoint.get("ServiceName")),
                  "vpc_endpoint_type": endpoint_type,
                  "state": endpoint.get("State"),
                  "private_dns_enabled": endpoint.get("PrivateDnsEnabled"),
                  "subnet_count": len(subnet_ids),
                  "subnet_ids": subnet_ids,
                  "route_table_count": len(route_table_ids),
                  "route_table_ids": route_table_ids,
                  "security_group_count": len(groups),
                  "security_groups": groups,
                  "network_interface_count": len(network_interface_ids),
                  "network_interface_ids": network_interface_ids,
                  "dns_entry_count": len(dns_entries),
                  "owner_id": endpoint.get("OwnerId"),
                  "requester_managed": endpoint.get("RequesterManaged"),
                  "creation_timestamp": endpoint.get("CreationTimestamp"),
                  "policy_document_present": bool(endpoint.get("PolicyDocument")),
                  "ip_address_type": endpoint.get("IpAddressType"),
                  "dns_options": endpoint.get("DnsOptions")
              }
              if include_tags:
                  item["tags"] = tags_to_dict(endpoint.get("Tags"))
              return item

          def scan_region(region, include_details, include_tags):
              rr = {
                  "region": region,
                  "vpc_endpoints": 0,
                  "interface_endpoints": 0,
                  "gateway_endpoints": 0,
                  "gateway_load_balancer_endpoints": 0,
                  "resource_endpoints": 0,
                  "service_network_endpoints": 0,
                  "other_endpoints": 0,
                  "available_endpoints": 0,
                  "pending_endpoints": 0,
                  "failed_endpoints": 0,
                  "rejected_endpoints": 0,
                  "deleting_endpoints": 0,
                  "subnet_attachments": 0,
                  "route_table_attachments": 0,
                  "network_interfaces": 0,
                  "security_group_attachments": 0,
                  "requester_managed_endpoints": 0,
                  "private_dns_enabled_interface_endpoints": 0,
                  "endpoints_with_policy_document": 0,
                  "services": {},
                  "interface_services": {},
                  "review_flags": [],
                  "endpoint_inventory": [],
                  "access_denied": 0,
                  "failed": 0,
                  "failures": []
              }

              ec2 = boto3.client("ec2", region_name=region)
              endpoints = safe_call(
                  rr,
                  "EC2",
                  "DescribeVpcEndpoints",
                  lambda: paginate(ec2, "describe_vpc_endpoints", "VpcEndpoints"),
                  []
              )

              rr["vpc_endpoints"] = len(endpoints)

              for ep in endpoints:
                  endpoint_type = ep.get("VpcEndpointType") or "Unknown"
                  state = ep.get("State") or "unknown"
                  service = service_short_name(ep.get("ServiceName"))

                  rr["services"][service] = rr["services"].get(service, 0) + 1

                  if endpoint_type == "Interface":
                      rr["interface_endpoints"] += 1
                      rr["interface_services"][service] = rr["interface_services"].get(service, 0) + 1
                      if ep.get("PrivateDnsEnabled"):
                          rr["private_dns_enabled_interface_endpoints"] += 1
                  elif endpoint_type == "Gateway":
                      rr["gateway_endpoints"] += 1
                  elif endpoint_type == "GatewayLoadBalancer":
                      rr["gateway_load_balancer_endpoints"] += 1
                  elif endpoint_type == "Resource":
                      rr["resource_endpoints"] += 1
                  elif endpoint_type == "ServiceNetwork":
                      rr["service_network_endpoints"] += 1
                  else:
                      rr["other_endpoints"] += 1

                  if state == "available":
                      rr["available_endpoints"] += 1
                  elif state == "pending":
                      rr["pending_endpoints"] += 1
                  elif state == "failed":
                      rr["failed_endpoints"] += 1
                  elif state == "rejected":
                      rr["rejected_endpoints"] += 1
                  elif state == "deleting":
                      rr["deleting_endpoints"] += 1

                  rr["subnet_attachments"] += len(ep.get("SubnetIds", []) or [])
                  rr["route_table_attachments"] += len(ep.get("RouteTableIds", []) or [])
                  rr["network_interfaces"] += len(ep.get("NetworkInterfaceIds", []) or [])
                  rr["security_group_attachments"] += len(ep.get("Groups", []) or [])

                  if ep.get("RequesterManaged"):
                      rr["requester_managed_endpoints"] += 1

                  if ep.get("PolicyDocument"):
                      rr["endpoints_with_policy_document"] += 1

                  if include_details:
                      rr["endpoint_inventory"].append(endpoint_inventory_item(ep, include_tags))

              if rr["interface_endpoints"] > 0:
                  rr["review_flags"].append("interface_endpoints_present")
              if rr["gateway_load_balancer_endpoints"] > 0:
                  rr["review_flags"].append("gateway_load_balancer_endpoints_present")
              if rr["pending_endpoints"] or rr["failed_endpoints"] or rr["rejected_endpoints"]:
                  rr["review_flags"].append("non_available_endpoints_present")
              if rr["requester_managed_endpoints"] > 0:
                  rr["review_flags"].append("requester_managed_endpoints_present")

              return rr

          def handler(event, context):
              event = event or {}
              excluded = set(csv_list(event.get("excluded_regions_csv") or os.environ.get("EXCLUDED_REGIONS_CSV", "ap-northeast-3")))
              include_details = parse_bool(event.get("include_endpoint_details"), env_bool("INCLUDE_ENDPOINT_DETAILS", True))
              include_tags = parse_bool(event.get("include_tags"), env_bool("INCLUDE_TAGS", True))

              summary = {
                  "started_at": utc_now().isoformat(),
                  "review_only": True,
                  "include_endpoint_details": include_details,
                  "include_tags": include_tags,
                  "excluded_regions": sorted(list(excluded)),
                  "totals": {
                      "regions_scanned": 0,
                      "vpc_endpoints": 0,
                      "interface_endpoints": 0,
                      "gateway_endpoints": 0,
                      "gateway_load_balancer_endpoints": 0,
                      "resource_endpoints": 0,
                      "service_network_endpoints": 0,
                      "other_endpoints": 0,
                      "available_endpoints": 0,
                      "pending_endpoints": 0,
                      "failed_endpoints": 0,
                      "rejected_endpoints": 0,
                      "deleting_endpoints": 0,
                      "subnet_attachments": 0,
                      "route_table_attachments": 0,
                      "network_interfaces": 0,
                      "security_group_attachments": 0,
                      "requester_managed_endpoints": 0,
                      "private_dns_enabled_interface_endpoints": 0,
                      "endpoints_with_policy_document": 0,
                      "regions_with_interface_endpoints": 0,
                      "regions_with_gateway_endpoints": 0,
                      "regions_with_endpoint_review_flags": 0,
                      "access_denied": 0,
                      "failed": 0
                  },
                  "services": {},
                  "interface_services": {},
                  "region_results": [],
                  "failures": []
              }

              try:
                  regions = get_enabled_regions(excluded)
              except Exception as e:
                  summary["totals"]["failed"] += 1
                  summary["failures"].append(error_record("EC2", "DescribeRegions", e))
                  summary["ended_at"] = utc_now().isoformat()
                  print(json.dumps(summary, indent=2, default=str))
                  return json.loads(json.dumps(summary, default=str))

              for region in regions:
                  rr = scan_region(region, include_details, include_tags)

                  for key in [
                      "vpc_endpoints",
                      "interface_endpoints",
                      "gateway_endpoints",
                      "gateway_load_balancer_endpoints",
                      "resource_endpoints",
                      "service_network_endpoints",
                      "other_endpoints",
                      "available_endpoints",
                      "pending_endpoints",
                      "failed_endpoints",
                      "rejected_endpoints",
                      "deleting_endpoints",
                      "subnet_attachments",
                      "route_table_attachments",
                      "network_interfaces",
                      "security_group_attachments",
                      "requester_managed_endpoints",
                      "private_dns_enabled_interface_endpoints",
                      "endpoints_with_policy_document",
                      "access_denied",
                      "failed"
                  ]:
                      summary["totals"][key] += int(rr.get(key, 0) or 0)

                  if rr.get("interface_endpoints", 0) > 0:
                      summary["totals"]["regions_with_interface_endpoints"] += 1
                  if rr.get("gateway_endpoints", 0) > 0:
                      summary["totals"]["regions_with_gateway_endpoints"] += 1
                  if rr.get("review_flags"):
                      summary["totals"]["regions_with_endpoint_review_flags"] += 1

                  for service, count in rr.get("services", {}).items():
                      summary["services"][service] = summary["services"].get(service, 0) + count
                  for service, count in rr.get("interface_services", {}).items():
                      summary["interface_services"][service] = summary["interface_services"].get(service, 0) + count

                  summary["totals"]["regions_scanned"] += 1
                  summary["failures"].extend([dict(f, region=region) for f in rr.get("failures", [])])
                  summary["region_results"].append(rr)

              summary["ended_at"] = utc_now().isoformat()
              print(json.dumps(summary, indent=2, default=str))
              return json.loads(json.dumps(summary, default=str))

  ReviewScheduleRule:
    Type: AWS::Events::Rule
    Condition: IsScheduleEnabled
    Properties:
      Name: !Sub '${ProjectName}-schedule'
      Description: Scheduled run for VPC Endpoint Cost Review Remediator.
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt ReviewFunction.Arn
          Id: VpcEndpointCostReviewTarget
          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'
