AWSTemplateFormatVersion: '2010-09-09'
Description: AWS CodeBuild Project and Build 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
  IncludeProjectDetails:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
  IncludeRecentBuilds:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
  IncludeTags:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
  MaxProjectsPerRegion:
    Type: Number
    Default: 200
    MinValue: 1
    MaxValue: 500
  MaxBuildsPerProject:
    Type: Number
    Default: 20
    MinValue: 1
    MaxValue: 100
  LongTimeoutMinutesThreshold:
    Type: Number
    Default: 60
    MinValue: 5
    MaxValue: 480

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

Resources:
  CodeBuildCostReviewRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: [lambda.amazonaws.com]
            Action: [sts:AssumeRole]
      Policies:
        - PolicyName: codebuild-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: CodeBuildReadOnly
                Effect: Allow
                Action:
                  - codebuild:ListProjects
                  - codebuild:BatchGetProjects
                  - codebuild:ListBuildsForProject
                  - codebuild:BatchGetBuilds
                  - codebuild:ListBuildBatchesForProject
                  - codebuild:BatchGetBuildBatches
                Resource: '*'

  CodeBuildCostReviewFunction:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: python3.12
      Handler: index.lambda_handler
      Role: !GetAtt CodeBuildCostReviewRole.Arn
      Timeout: 900
      MemorySize: 256
      Environment:
        Variables:
          EXCLUDED_REGIONS_CSV: !Ref ExcludedRegionsCsv
          INCLUDE_PROJECT_DETAILS: !Ref IncludeProjectDetails
          INCLUDE_RECENT_BUILDS: !Ref IncludeRecentBuilds
          INCLUDE_TAGS: !Ref IncludeTags
          MAX_PROJECTS_PER_REGION: !Ref MaxProjectsPerRegion
          MAX_BUILDS_PER_PROJECT: !Ref MaxBuildsPerProject
          LONG_TIMEOUT_MINUTES_THRESHOLD: !Ref LongTimeoutMinutesThreshold
      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)

          def now_iso():
              return now().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_projects(client, max_projects):
              projects = []
              token = None
              while True:
                  kwargs = {"sortBy": "NAME", "sortOrder": "ASCENDING"}
                  if token:
                      kwargs["nextToken"] = token
                  response = client.list_projects(**kwargs)
                  projects.extend(response.get("projects", []) or [])
                  if len(projects) >= max_projects:
                      return projects[:max_projects], True
                  token = response.get("nextToken")
                  if not token:
                      return projects, False

          def batch_get_projects(client, names):
              projects = []
              for chunk in chunked(names, 100):
                  response = client.batch_get_projects(names=chunk)
                  projects.extend(response.get("projects", []) or [])
              return projects

          def list_recent_build_ids(client, project_name, max_builds):
              ids = []
              token = None
              while True:
                  kwargs = {
                      "projectName": project_name,
                      "sortOrder": "DESCENDING"
                  }
                  if token:
                      kwargs["nextToken"] = token
                  response = client.list_builds_for_project(**kwargs)
                  ids.extend(response.get("ids", []) or [])
                  if len(ids) >= max_builds:
                      return ids[:max_builds], True
                  token = response.get("nextToken")
                  if not token:
                      return ids, False

          def batch_get_builds(client, ids):
              builds = []
              for chunk in chunked(ids, 100):
                  if not chunk:
                      continue
                  response = client.batch_get_builds(ids=chunk)
                  builds.extend(response.get("builds", []) or [])
              return builds

          def duration_minutes(build):
              start = build.get("startTime")
              end = build.get("endTime")
              try:
                  if start and end:
                      return round((end - start).total_seconds() / 60, 2)
              except Exception:
                  pass
              return None

          def logging_summary(logs_config):
              logs_config = logs_config if isinstance(logs_config, dict) else {}
              cw = logs_config.get("cloudWatchLogs", {}) if isinstance(logs_config.get("cloudWatchLogs"), dict) else {}
              s3 = logs_config.get("s3Logs", {}) if isinstance(logs_config.get("s3Logs"), dict) else {}
              return {
                  "cloudwatch_status": cw.get("status"),
                  "s3_status": s3.get("status"),
                  "cloudwatch_enabled": cw.get("status") == "ENABLED",
                  "s3_enabled": s3.get("status") == "ENABLED"
              }

          def summarize_build(build):
              phases = build.get("phases") or []
              queued_minutes = None
              build_minutes = duration_minutes(build)
              return {
                  "id": build.get("id"),
                  "build_number": build.get("buildNumber"),
                  "status": build.get("buildStatus"),
                  "current_phase": build.get("currentPhase"),
                  "start_time": str(build.get("startTime")) if build.get("startTime") else None,
                  "end_time": str(build.get("endTime")) if build.get("endTime") else None,
                  "duration_minutes": build_minutes,
                  "queued_timeout_in_minutes": build.get("queuedTimeoutInMinutes"),
                  "timeout_in_minutes": build.get("timeoutInMinutes"),
                  "initiator": build.get("initiator"),
                  "source_version": build.get("sourceVersion"),
                  "resolved_source_version_present": bool(build.get("resolvedSourceVersion")),
                  "phases_count": len(phases)
              }

          def summarize_project(region, client, project, include_recent_builds, include_tags, max_builds, long_timeout):
              env = project.get("environment") or {}
              cache = project.get("cache") or {}
              logs = logging_summary(project.get("logsConfig"))
              artifacts = project.get("artifacts") or {}
              secondary_artifacts = project.get("secondaryArtifacts") or []
              source = project.get("source") or {}
              secondary_sources = project.get("secondarySources") or []
              vpc_config = project.get("vpcConfig") or {}

              item = {
                  "region": region,
                  "name": project.get("name"),
                  "arn": project.get("arn"),
                  "description": project.get("description"),
                  "created": str(project.get("created")) if project.get("created") else None,
                  "last_modified": str(project.get("lastModified")) if project.get("lastModified") else None,
                  "service_role_present": bool(project.get("serviceRole")),
                  "badge_enabled": project.get("badge", {}).get("badgeEnabled") if isinstance(project.get("badge"), dict) else None,
                  "encryption_key_present": bool(project.get("encryptionKey")),
                  "timeout_in_minutes": project.get("timeoutInMinutes"),
                  "queued_timeout_in_minutes": project.get("queuedTimeoutInMinutes"),
                  "concurrent_build_limit": project.get("concurrentBuildLimit"),
                  "source_type": source.get("type"),
                  "secondary_sources_count": len(secondary_sources),
                  "artifacts_type": artifacts.get("type"),
                  "secondary_artifacts_count": len(secondary_artifacts),
                  "environment_type": env.get("type"),
                  "image": env.get("image"),
                  "compute_type": env.get("computeType"),
                  "privileged_mode": env.get("privilegedMode"),
                  "image_pull_credentials_type": env.get("imagePullCredentialsType"),
                  "environment_variables_count": len(env.get("environmentVariables") or []),
                  "cache_type": cache.get("type"),
                  "cache_modes": cache.get("modes") or [],
                  "logs": logs,
                  "vpc_config_present": bool(vpc_config.get("vpcId")),
                  "vpc_subnets_count": len(vpc_config.get("subnets") or []),
                  "vpc_security_group_count": len(vpc_config.get("securityGroupIds") or []),
                  "recent_builds": [],
                  "recent_builds_truncated": False,
                  "recent_build_count": 0,
                  "recent_succeeded_builds": 0,
                  "recent_failed_builds": 0,
                  "recent_fault_builds": 0,
                  "recent_stopped_builds": 0,
                  "recent_in_progress_builds": 0,
                  "recent_build_minutes": 0,
                  "review_flags": []
              }

              tags = project.get("tags") or []
              if include_tags:
                  item["tags"] = {t.get("key"): t.get("value") for t in tags if t.get("key")}
                  item["tag_count"] = len(tags)

              if include_recent_builds:
                  try:
                      ids, truncated = list_recent_build_ids(client, project.get("name"), max_builds)
                      builds = batch_get_builds(client, ids)
                      item["recent_builds_truncated"] = truncated
                      item["recent_build_count"] = len(builds)
                      summaries = []

                      for build in builds:
                          status = build.get("buildStatus")
                          if status == "SUCCEEDED":
                              item["recent_succeeded_builds"] += 1
                          elif status == "FAILED":
                              item["recent_failed_builds"] += 1
                          elif status == "FAULT":
                              item["recent_fault_builds"] += 1
                          elif status == "STOPPED":
                              item["recent_stopped_builds"] += 1
                          elif status == "IN_PROGRESS":
                              item["recent_in_progress_builds"] += 1

                          minutes = duration_minutes(build)
                          if minutes:
                              item["recent_build_minutes"] += minutes

                          summaries.append(summarize_build(build))

                      item["recent_build_minutes"] = round(item["recent_build_minutes"], 2)
                      item["recent_builds"] = summaries

                  except ClientError as e:
                      item["recent_builds_error"] = e.response.get("Error", {}).get("Code", "ClientError")
                  except Exception as e:
                      item["recent_builds_error"] = str(e)

              flags = []
              compute_type = str(item.get("compute_type") or "")
              cache_type = str(item.get("cache_type") or "")
              timeout = item.get("timeout_in_minutes") or 0

              if item.get("recent_build_count", 0) > 0:
                  flags.append("recent_build_activity_review")
              if item.get("recent_in_progress_builds", 0) > 0:
                  flags.append("in_progress_builds_review")
              if item.get("recent_failed_builds", 0) + item.get("recent_fault_builds", 0) > 0:
                  flags.append("recent_failed_or_fault_builds_review")
              if any(x in compute_type for x in ["LARGE", "2XLARGE"]):
                  flags.append("larger_compute_type_review")
              if item.get("privileged_mode") is True:
                  flags.append("privileged_build_review")
              if timeout >= long_timeout:
                  flags.append("long_timeout_review")
              if item.get("queued_timeout_in_minutes", 0) and item.get("queued_timeout_in_minutes") >= long_timeout:
                  flags.append("long_queued_timeout_review")
              if cache_type == "LOCAL":
                  flags.append("local_cache_review")
              if item.get("logs", {}).get("cloudwatch_enabled") is True or item.get("logs", {}).get("s3_enabled") is True:
                  flags.append("build_logging_enabled_review")
              if item.get("vpc_config_present") is True:
                  flags.append("vpc_configured_build_review")
              if item.get("concurrent_build_limit") and item.get("concurrent_build_limit") > 1:
                  flags.append("concurrent_build_limit_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_details = as_bool(event.get("include_details", os.environ.get("INCLUDE_PROJECT_DETAILS", "true")), True)
              include_recent_builds = as_bool(event.get("include_recent_builds", os.environ.get("INCLUDE_RECENT_BUILDS", "true")), True)
              include_tags = as_bool(event.get("include_tags", os.environ.get("INCLUDE_TAGS", "true")), True)
              max_projects = as_int(event.get("max_projects_per_region", os.environ.get("MAX_PROJECTS_PER_REGION", "200")), 200)
              max_builds = as_int(event.get("max_builds_per_project", os.environ.get("MAX_BUILDS_PER_PROJECT", "20")), 20)
              long_timeout = as_int(event.get("long_timeout_minutes_threshold", os.environ.get("LONG_TIMEOUT_MINUTES_THRESHOLD", "60")), 60)

              result = {
                  "module": "aws_codebuild_project_build_cost_review",
                  "review_only": True,
                  "started_at": now_iso(),
                  "ended_at": None,
                  "excluded_regions": sorted(excluded),
                  "totals": {
                      "regions_scanned": 0,
                      "regions_skipped": 0,
                      "projects": 0,
                      "projects_with_recent_builds": 0,
                      "projects_with_in_progress_builds": 0,
                      "projects_with_failed_or_fault_builds": 0,
                      "larger_compute_type_projects": 0,
                      "privileged_build_projects": 0,
                      "long_timeout_projects": 0,
                      "local_cache_projects": 0,
                      "vpc_configured_projects": 0,
                      "logging_enabled_projects": 0,
                      "recent_builds_sampled": 0,
                      "recent_succeeded_builds": 0,
                      "recent_failed_builds": 0,
                      "recent_fault_builds": 0,
                      "recent_stopped_builds": 0,
                      "recent_in_progress_builds": 0,
                      "recent_build_minutes": 0,
                      "regions_with_projects": 0,
                      "regions_with_review_flags": 0,
                      "truncated_regions": 0,
                      "access_denied": 0,
                      "failed": 0
                  },
                  "regions": [],
                  "projects": []
              }

              try:
                  regions = enabled_regions()
              except Exception as e:
                  result["fatal_error"] = str(e)
                  result["traceback"] = traceback.format_exc()
                  result["ended_at"] = now_iso()
                  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,
                      "projects": 0,
                      "projects_with_recent_builds": 0,
                      "projects_with_in_progress_builds": 0,
                      "projects_with_failed_or_fault_builds": 0,
                      "larger_compute_type_projects": 0,
                      "privileged_build_projects": 0,
                      "long_timeout_projects": 0,
                      "local_cache_projects": 0,
                      "vpc_configured_projects": 0,
                      "logging_enabled_projects": 0,
                      "recent_builds_sampled": 0,
                      "recent_build_minutes": 0,
                      "review_flags": [],
                      "truncated": False,
                      "error": None
                  }

                  try:
                      client = boto3.client("codebuild", region_name=region)
                      names, truncated = list_projects(client, max_projects)
                      rec["truncated"] = truncated
                      if truncated:
                          result["totals"]["truncated_regions"] += 1

                      projects = batch_get_projects(client, names) if names else []
                      details = []

                      for project in projects:
                          d = summarize_project(region, client, project, include_recent_builds, include_tags, max_builds, long_timeout)
                          details.append(d)
                          rec["projects"] += 1
                          result["totals"]["projects"] += 1

                          if d.get("recent_build_count", 0) > 0:
                              rec["projects_with_recent_builds"] += 1
                              result["totals"]["projects_with_recent_builds"] += 1
                          if d.get("recent_in_progress_builds", 0) > 0:
                              rec["projects_with_in_progress_builds"] += 1
                              result["totals"]["projects_with_in_progress_builds"] += 1
                          if d.get("recent_failed_builds", 0) + d.get("recent_fault_builds", 0) > 0:
                              rec["projects_with_failed_or_fault_builds"] += 1
                              result["totals"]["projects_with_failed_or_fault_builds"] += 1

                          flags = d.get("review_flags", [])
                          if "larger_compute_type_review" in flags:
                              rec["larger_compute_type_projects"] += 1
                              result["totals"]["larger_compute_type_projects"] += 1
                          if "privileged_build_review" in flags:
                              rec["privileged_build_projects"] += 1
                              result["totals"]["privileged_build_projects"] += 1
                          if "long_timeout_review" in flags:
                              rec["long_timeout_projects"] += 1
                              result["totals"]["long_timeout_projects"] += 1
                          if "local_cache_review" in flags:
                              rec["local_cache_projects"] += 1
                              result["totals"]["local_cache_projects"] += 1
                          if "vpc_configured_build_review" in flags:
                              rec["vpc_configured_projects"] += 1
                              result["totals"]["vpc_configured_projects"] += 1
                          if "build_logging_enabled_review" in flags:
                              rec["logging_enabled_projects"] += 1
                              result["totals"]["logging_enabled_projects"] += 1

                          rec["recent_builds_sampled"] += d.get("recent_build_count", 0)
                          rec["recent_build_minutes"] += d.get("recent_build_minutes", 0)
                          result["totals"]["recent_builds_sampled"] += d.get("recent_build_count", 0)
                          result["totals"]["recent_succeeded_builds"] += d.get("recent_succeeded_builds", 0)
                          result["totals"]["recent_failed_builds"] += d.get("recent_failed_builds", 0)
                          result["totals"]["recent_fault_builds"] += d.get("recent_fault_builds", 0)
                          result["totals"]["recent_stopped_builds"] += d.get("recent_stopped_builds", 0)
                          result["totals"]["recent_in_progress_builds"] += d.get("recent_in_progress_builds", 0)
                          result["totals"]["recent_build_minutes"] += d.get("recent_build_minutes", 0)

                          rec["review_flags"].extend(flags)

                      rec["recent_build_minutes"] = round(rec["recent_build_minutes"], 2)
                      rec["review_flags"] = sorted(set(rec["review_flags"]))

                      if rec["projects"] > 0:
                          result["totals"]["regions_with_projects"] += 1
                      if rec["review_flags"]:
                          result["totals"]["regions_with_review_flags"] += 1

                      if include_details:
                          result["projects"].extend(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["totals"]["recent_build_minutes"] = round(result["totals"]["recent_build_minutes"], 2)
              result["ended_at"] = now_iso()
              return result

  CodeBuildCostReviewSchedule:
    Type: AWS::Events::Rule
    Condition: CreateSchedule
    Properties:
      Description: Scheduled review-only AWS CodeBuild project and build cost review.
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt CodeBuildCostReviewFunction.Arn
          Id: CodeBuildCostReviewFunctionTarget
          Input: '{"scheduled":true}'

  CodeBuildCostReviewSchedulePermission:
    Type: AWS::Lambda::Permission
    Condition: CreateSchedule
    Properties:
      FunctionName: !Ref CodeBuildCostReviewFunction
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt CodeBuildCostReviewSchedule.Arn

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