AWSTemplateFormatVersion: '2010-09-09'
Description: >
  Developer Tools Cost Review Remediator v3.
  Review-only inventory of CodeBuild, CodePipeline, and CodeArtifact resources
  across enabled Regions.

Parameters:
  ProjectName:
    Type: String
    Default: developer-tools-cost-review
  IncludeDetails:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
  MaxBuildsPerProject:
    Type: Number
    Default: 25
    MinValue: 1
    MaxValue: 100
  MaxPipelineExecutionsPerPipeline:
    Type: Number
    Default: 10
    MinValue: 1
    MaxValue: 100
  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: DeveloperToolsCostReviewReadOnlyPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: ec2:DescribeRegions
                Resource: '*'
              - Effect: Allow
                Action:
                  - codebuild:ListProjects
                  - codebuild:BatchGetProjects
                  - codebuild:ListBuildsForProject
                  - codebuild:BatchGetBuilds
                  - codebuild:ListReportGroups
                  - codebuild:BatchGetReportGroups
                  - codepipeline:ListPipelines
                  - codepipeline:GetPipeline
                  - codepipeline:ListPipelineExecutions
                  - codeartifact:ListDomains
                  - codeartifact:ListRepositoriesInDomain
                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_BUILDS_PER_PROJECT: !Ref MaxBuildsPerProject
          MAX_PIPELINE_EXECUTIONS_PER_PIPELINE: !Ref MaxPipelineExecutionsPerPipeline
          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:
                  code = (e.response.get("Error", {}).get("Code", "") or "").lower()
                  msg = (e.response.get("Error", {}).get("Message", "") or "").lower()
                  if (
                      "unknownendpoint" in code
                      or "could not connect" in msg
                      or "unknown operation" in msg
                      or code == "invalidinputexception" and "unknown operation" 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 AttributeError:
                  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 chunks(items, size):
              for i in range(0, len(items), size):
                  yield items[i:i+size]

          def list_some(call, key, max_items, 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 [])
                  if len(out) >= max_items:
                      return out[:max_items]
                  token = resp.get(out_token_name)
                  if not token:
                      return out

          def scan_codebuild(rr, region, details, max_builds):
              cb = boto3.client("codebuild", region_name=region)

              project_names = safe(rr, "CodeBuild", "ListProjects", lambda: list_all(cb.list_projects, "projects"), [])
              rr["codebuild_projects"] = len(project_names)

              projects = []
              for group in chunks(project_names, 100):
                  projects.extend(safe(rr, "CodeBuild", "BatchGetProjects", lambda g=group: cb.batch_get_projects(names=g).get("projects", []), []))

              for p in projects:
                  queued_timeout = int(p.get("queuedTimeoutInMinutes") or 0)
                  timeout = int(p.get("timeoutInMinutes") or 0)
                  if p.get("badge", {}).get("badgeEnabled"):
                      rr["codebuild_badge_enabled_projects"] += 1
                  if (p.get("cache") or {}).get("type") not in (None, "NO_CACHE"):
                      rr["codebuild_projects_with_cache"] += 1

                  if details:
                      env = p.get("environment") or {}
                      rr["codebuild_project_inventory"].append({
                          "name": p.get("name"),
                          "arn": p.get("arn"),
                          "created": p.get("created"),
                          "last_modified": p.get("lastModified"),
                          "source_type": (p.get("source") or {}).get("type"),
                          "artifacts_type": (p.get("artifacts") or {}).get("type"),
                          "environment_type": env.get("type"),
                          "compute_type": env.get("computeType"),
                          "image": env.get("image"),
                          "privileged_mode": env.get("privilegedMode"),
                          "timeout_minutes": timeout,
                          "queued_timeout_minutes": queued_timeout,
                          "cache_type": (p.get("cache") or {}).get("type"),
                          "badge_enabled": p.get("badge", {}).get("badgeEnabled"),
                          "tags": p.get("tags") or []
                      })

                  build_ids = safe(rr, "CodeBuild", "ListBuildsForProject", lambda n=p.get("name"): list_some(cb.list_builds_for_project, "ids", max_builds, projectName=n), [])
                  rr["codebuild_builds_sampled"] += len(build_ids)
                  for bg in chunks(build_ids, 100):
                      builds = safe(rr, "CodeBuild", "BatchGetBuilds", lambda ids=bg: cb.batch_get_builds(ids=ids).get("builds", []), [])
                      for b in builds:
                          status = b.get("buildStatus")
                          if status == "IN_PROGRESS":
                              rr["codebuild_builds_in_progress_sampled"] += 1
                          elif status == "SUCCEEDED":
                              rr["codebuild_builds_succeeded_sampled"] += 1
                          elif status == "FAILED":
                              rr["codebuild_builds_failed_sampled"] += 1
                          elif status == "STOPPED":
                              rr["codebuild_builds_stopped_sampled"] += 1
                          if details:
                              rr["codebuild_build_samples"].append({
                                  "project_name": p.get("name"),
                                  "id": b.get("id"),
                                  "arn": b.get("arn"),
                                  "build_number": b.get("buildNumber"),
                                  "status": status,
                                  "current_phase": b.get("currentPhase"),
                                  "start_time": b.get("startTime"),
                                  "end_time": b.get("endTime"),
                                  "initiator": b.get("initiator")
                              })

              report_arns = safe(rr, "CodeBuild", "ListReportGroups", lambda: list_all(cb.list_report_groups, "reportGroups"), [])
              rr["codebuild_report_groups"] = len(report_arns)
              for group in chunks(report_arns, 100):
                  reports = safe(rr, "CodeBuild", "BatchGetReportGroups", lambda g=group: cb.batch_get_report_groups(reportGroupArns=g).get("reportGroups", []), [])
                  for rg in reports:
                      if details:
                          rr["codebuild_report_group_inventory"].append({
                              "name": rg.get("name"),
                              "arn": rg.get("arn"),
                              "type": rg.get("type"),
                              "created": rg.get("created"),
                              "last_modified": rg.get("lastModified"),
                              "export_config_type": (rg.get("exportConfig") or {}).get("exportConfigType"),
                              "tags": rg.get("tags") or []
                          })

              # CodeBuild reserved capacity fleet APIs are intentionally not queried in this module.
              # The API surface is not consistently available across Regions/accounts and caused noisy failures.

          def scan_codepipeline(rr, region, details, max_execs):
              cp = boto3.client("codepipeline", region_name=region)
              pipelines = safe(rr, "CodePipeline", "ListPipelines", lambda: list_all(cp.list_pipelines, "pipelines"), [])
              rr["codepipeline_pipelines"] = len(pipelines)

              for p in pipelines:
                  name = p.get("name")
                  if details:
                      pipe = safe(rr, "CodePipeline", "GetPipeline", lambda n=name: cp.get_pipeline(name=n).get("pipeline", {}), {})
                      stages = pipe.get("stages") or []
                      rr["codepipeline_pipeline_inventory"].append({
                          "name": name,
                          "version": p.get("version"),
                          "created": p.get("created"),
                          "updated": p.get("updated"),
                          "pipeline_type": pipe.get("pipelineType"),
                          "execution_mode": pipe.get("executionMode"),
                          "artifact_store_type": (pipe.get("artifactStore") or {}).get("type"),
                          "stage_count": len(stages),
                          "actions_count": sum(len(s.get("actions") or []) for s in stages)
                      })

                  execs = safe(rr, "CodePipeline", "ListPipelineExecutions", lambda n=name: cp.list_pipeline_executions(pipelineName=n, maxResults=max_execs).get("pipelineExecutionSummaries", []), [])
                  rr["codepipeline_executions_sampled"] += len(execs)
                  for e in execs:
                      status = e.get("status")
                      if status == "InProgress":
                          rr["codepipeline_executions_in_progress_sampled"] += 1
                      elif status == "Succeeded":
                          rr["codepipeline_executions_succeeded_sampled"] += 1
                      elif status == "Failed":
                          rr["codepipeline_executions_failed_sampled"] += 1
                      elif status == "Stopped":
                          rr["codepipeline_executions_stopped_sampled"] += 1
                      if details:
                          rr["codepipeline_execution_samples"].append({
                              "pipeline_name": name,
                              "execution_id": e.get("pipelineExecutionId"),
                              "status": status,
                              "start_time": e.get("startTime"),
                              "last_update_time": e.get("lastUpdateTime"),
                              "source_revisions_count": len(e.get("sourceRevisions") or [])
                          })

          def scan_codeartifact(rr, region, details):
              ca = boto3.client("codeartifact", region_name=region)
              domains = safe_optional_endpoint(rr, "CodeArtifact", "ListDomains", lambda: list_all(ca.list_domains, "domains", token_name="nextToken", out_token_name="nextToken"), [])
              rr["codeartifact_domains"] = len(domains)

              for d in domains:
                  domain_name = d.get("name")
                  rr["codeartifact_encrypted_domains"] += 1 if d.get("encryptionKey") else 0
                  repos = safe_optional_endpoint(rr, "CodeArtifact", "ListRepositoriesInDomain", lambda dn=domain_name: list_all(ca.list_repositories_in_domain, "repositories", token_name="nextToken", out_token_name="nextToken", domain=dn), [])
                  rr["codeartifact_repositories"] += len(repos)
                  if details:
                      rr["codeartifact_domain_inventory"].append({
                          "name": domain_name,
                          "arn": d.get("arn"),
                          "owner": d.get("owner"),
                          "status": d.get("status"),
                          "encryption_key_present": bool(d.get("encryptionKey")),
                          "repository_count": len(repos)
                      })
                      for r in repos:
                          rr["codeartifact_repository_inventory"].append({
                              "domain_name": domain_name,
                              "repository_name": r.get("name"),
                              "arn": r.get("arn"),
                              "administrator_account": r.get("administratorAccount")
                          })

          def scan(region, details, max_builds, max_execs):
              rr = {
                  "region": region,
                  "codebuild_projects": 0,
                  "codebuild_projects_with_cache": 0,
                  "codebuild_badge_enabled_projects": 0,
                  "codebuild_builds_sampled": 0,
                  "codebuild_builds_in_progress_sampled": 0,
                  "codebuild_builds_succeeded_sampled": 0,
                  "codebuild_builds_failed_sampled": 0,
                  "codebuild_builds_stopped_sampled": 0,
                  "codebuild_report_groups": 0,
                  "codebuild_fleets": 0,
                  "codepipeline_pipelines": 0,
                  "codepipeline_executions_sampled": 0,
                  "codepipeline_executions_in_progress_sampled": 0,
                  "codepipeline_executions_succeeded_sampled": 0,
                  "codepipeline_executions_failed_sampled": 0,
                  "codepipeline_executions_stopped_sampled": 0,
                  "codeartifact_domains": 0,
                  "codeartifact_encrypted_domains": 0,
                  "codeartifact_repositories": 0,
                  "review_flags": [],
                  "codebuild_project_inventory": [],
                  "codebuild_build_samples": [],
                  "codebuild_report_group_inventory": [],
                  "codebuild_fleet_inventory": [],
                  "codepipeline_pipeline_inventory": [],
                  "codepipeline_execution_samples": [],
                  "codeartifact_domain_inventory": [],
                  "codeartifact_repository_inventory": [],
                  "access_denied": 0,
                  "failed": 0,
                  "failures": []
              }

              scan_codebuild(rr, region, details, max_builds)
              scan_codepipeline(rr, region, details, max_execs)
              scan_codeartifact(rr, region, details)

              if rr["codebuild_projects"] > 0:
                  rr["review_flags"].append("codebuild_projects_present")
              if rr["codebuild_builds_in_progress_sampled"] > 0:
                  rr["review_flags"].append("codebuild_in_progress_builds_sampled")
              if rr["codepipeline_pipelines"] > 0:
                  rr["review_flags"].append("codepipeline_pipelines_present")
              if rr["codepipeline_executions_in_progress_sampled"] > 0:
                  rr["review_flags"].append("codepipeline_in_progress_executions_sampled")
              if rr["codeartifact_domains"] > 0 or rr["codeartifact_repositories"] > 0:
                  rr["review_flags"].append("codeartifact_resources_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_builds = int(event.get("max_builds_per_project") or os.environ.get("MAX_BUILDS_PER_PROJECT", "25"))
              max_execs = int(event.get("max_pipeline_executions_per_pipeline") or os.environ.get("MAX_PIPELINE_EXECUTIONS_PER_PIPELINE", "10"))

              total_keys = [
                  "regions_scanned", "codebuild_projects", "codebuild_projects_with_cache",
                  "codebuild_badge_enabled_projects", "codebuild_builds_sampled",
                  "codebuild_builds_in_progress_sampled", "codebuild_builds_succeeded_sampled",
                  "codebuild_builds_failed_sampled", "codebuild_builds_stopped_sampled",
                  "codebuild_report_groups", "codebuild_fleets", "codepipeline_pipelines",
                  "codepipeline_executions_sampled", "codepipeline_executions_in_progress_sampled",
                  "codepipeline_executions_succeeded_sampled", "codepipeline_executions_failed_sampled",
                  "codepipeline_executions_stopped_sampled", "codeartifact_domains",
                  "codeartifact_encrypted_domains", "codeartifact_repositories",
                  "regions_with_codebuild_projects", "regions_with_codepipeline_pipelines",
                  "regions_with_codeartifact_resources", "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_builds, max_execs)
                  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["codebuild_projects"] > 0:
                      totals["regions_with_codebuild_projects"] += 1
                  if rr["codepipeline_pipelines"] > 0:
                      totals["regions_with_codepipeline_pipelines"] += 1
                  if rr["codeartifact_domains"] > 0 or rr["codeartifact_repositories"] > 0:
                      totals["regions_with_codeartifact_resources"] += 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: DeveloperToolsCostReviewTarget
          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'
