AWSTemplateFormatVersion: '2010-09-09'
Description: >
  CloudWatch Contributor Insights and Anomaly Detection Review Remediator.
  Inventories CloudWatch Contributor Insights rules and metric anomaly detectors
  across enabled Regions. Review-only by default. Optional action disables
  explicitly approved Contributor Insights rules. This module does not delete
  metrics, alarms, dashboards, or log groups.

Parameters:
  ProjectName:
    Type: String
    Default: cloudwatch-insights-anomaly-review
    Description: Short base name used for created resources.

  DryRun:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Preview only. No changes are made when true.

  ActionMode:
    Type: String
    Default: review-only
    AllowedValues:
      - review-only
      - disable-approved-contributor-insights
    Description: Action to perform. Review-only is the default.

  RequireApprovalTag:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Require approval tag before disabling Contributor Insights rules.

  ApprovalTagKey:
    Type: String
    Default: CostOptApproved
    Description: Tag key required before disabling Contributor Insights rules.

  ApprovalTagValue:
    Type: String
    Default: 'true'
    Description: Tag value required before disabling Contributor Insights rules.

  IncludeRuleNamePrefixesCsv:
    Type: String
    Default: ''
    Description: Optional comma-separated Contributor Insights rule name prefixes to include.

  ExcludeRuleNamePrefixesCsv:
    Type: String
    Default: ''
    Description: Optional comma-separated Contributor Insights rule name prefixes to exclude.

  IgnoreTagKeysCsv:
    Type: String
    Default: CostOptIgnore,DoNotModify,DoNotDisable,Production,prod
    Description: Tag keys that prevent action.

  IgnoreTagPairsCsv:
    Type: String
    Default: CostOptIgnore=true,DoNotModify=true,DoNotDisable=true,Environment=Production,Environment=Prod
    Description: Tag key=value pairs that prevent action.

  MaxRulesPerRun:
    Type: Number
    Default: 25
    MinValue: 1
    Description: Maximum Contributor Insights rules to evaluate for action per run.

  IncludeDetails:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Include detailed inventory in the Lambda response.

  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: CloudWatchInsightsAnomalyReviewPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: RegionDiscovery
                Effect: Allow
                Action:
                  - ec2:DescribeRegions
                Resource: '*'
              - Sid: CloudWatchRead
                Effect: Allow
                Action:
                  - cloudwatch:DescribeInsightRules
                  - cloudwatch:GetInsightRuleReport
                  - cloudwatch:ListTagsForResource
                  - cloudwatch:DescribeAnomalyDetectors
                Resource: '*'
              - Sid: CloudWatchDisableApprovedInsightsRules
                Effect: Allow
                Action:
                  - cloudwatch:DisableInsightRules
                Resource: '*'

  ReviewFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref ProjectName
      Description: Review CloudWatch Contributor Insights rules and anomaly detectors.
      Runtime: python3.12
      Handler: index.handler
      Timeout: 900
      MemorySize: 512
      Role: !GetAtt ReviewRole.Arn
      Environment:
        Variables:
          DEFAULT_DRY_RUN: !Ref DryRun
          DEFAULT_ACTION_MODE: !Ref ActionMode
          REQUIRE_APPROVAL_TAG: !Ref RequireApprovalTag
          APPROVAL_TAG_KEY: !Ref ApprovalTagKey
          APPROVAL_TAG_VALUE: !Ref ApprovalTagValue
          INCLUDE_RULE_NAME_PREFIXES_CSV: !Ref IncludeRuleNamePrefixesCsv
          EXCLUDE_RULE_NAME_PREFIXES_CSV: !Ref ExcludeRuleNamePrefixesCsv
          IGNORE_TAG_KEYS_CSV: !Ref IgnoreTagKeysCsv
          IGNORE_TAG_PAIRS_CSV: !Ref IgnoreTagPairsCsv
          MAX_RULES_PER_RUN: !Ref MaxRulesPerRun
          INCLUDE_DETAILS: !Ref IncludeDetails
          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 csv_pairs(value):
              pairs = {}
              for item in csv_list(value):
                  if "=" in item:
                      k, v = item.split("=", 1)
                      pairs[k.strip()] = v.strip()
              return pairs

          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 = dict(kwargs or {})

              # Prefer native botocore paginator when available.
              try:
                  paginator = client.get_paginator(operation)
                  items = []
                  for page in paginator.paginate(**kwargs):
                      items.extend(page.get(result_key, []) or [])
                  return items
              except Exception as paginator_error:
                  # Some CloudWatch APIs support NextToken in the API but do not expose
                  # a botocore paginator in the Lambda runtime. Fall back to manual paging.
                  if "Operation cannot be paginated" not in str(paginator_error):
                      raise

              method = getattr(client, operation)
              items = []
              token = None

              while True:
                  call_kwargs = dict(kwargs)
                  if token:
                      call_kwargs["NextToken"] = token

                  response = method(**call_kwargs)
                  items.extend(response.get(result_key, []) or [])

                  token = response.get("NextToken")
                  if not token:
                      break

              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 starts_with_any(name, prefixes):
              if not prefixes:
                  return True
              return any(name.startswith(p) for p in prefixes)

          def excluded_by_prefix(name, prefixes):
              return any(name.startswith(p) for p in prefixes)

          def has_ignore_tag(tags, ignore_keys, ignore_pairs):
              for key in ignore_keys:
                  if key in tags:
                      return True
              for key, expected in ignore_pairs.items():
                  if tags.get(key) == expected:
                      return True
              return False

          def has_approval_tag(tags, key, value):
              return str(tags.get(key, "")).lower() == str(value).lower()

          def get_rule_tags(client, rr, rule):
              arn = rule.get("ManagedRule") or rule.get("RuleArn")
              if not arn:
                  # DescribeInsightRules usually does not return an ARN in older SDK responses.
                  # ListTagsForResource requires an ARN, so tag checks become unavailable.
                  return {}, "tag_arn_unavailable"
              try:
                  resp = client.list_tags_for_resource(ResourceARN=arn)
                  return tags_to_dict(resp.get("Tags", [])), None
              except ClientError as e:
                  if is_access_denied(e):
                      rr["tag_read_access_denied"] += 1
                  else:
                      rr["failed"] += 1
                  rr["failures"].append(error_record("CloudWatch", "ListTagsForResource", e))
                  return {}, "tag_read_failed"

          def scan_region(region, settings):
              rr = {
                  "region": region,
                  "contributor_insight_rules": 0,
                  "enabled_contributor_insight_rules": 0,
                  "disabled_contributor_insight_rules": 0,
                  "managed_contributor_insight_rules": 0,
                  "custom_contributor_insight_rules": 0,
                  "anomaly_detectors": 0,
                  "single_metric_anomaly_detectors": 0,
                  "metric_math_anomaly_detectors": 0,
                  "eligible_rules": 0,
                  "would_disable_rules": 0,
                  "disabled_rules_by_action": 0,
                  "skipped_already_disabled": 0,
                  "skipped_include_prefix": 0,
                  "skipped_exclude_prefix": 0,
                  "skipped_managed_rule": 0,
                  "skipped_missing_approval_tag": 0,
                  "skipped_ignore_tag": 0,
                  "skipped_max_rules": 0,
                  "tag_read_access_denied": 0,
                  "tag_arn_unavailable": 0,
                  "access_denied": 0,
                  "failed": 0,
                  "failures": [],
                  "review_flags": [],
                  "rule_inventory": [],
                  "anomaly_detector_inventory": [],
                  "candidates": [],
                  "disabled_rule_names": []
              }

              client = boto3.client("cloudwatch", region_name=region)

              rules = safe_call(
                  rr,
                  "CloudWatch",
                  "DescribeInsightRules",
                  lambda: paginate(client, "describe_insight_rules", "InsightRules"),
                  []
              )

              detectors = safe_call(
                  rr,
                  "CloudWatch",
                  "DescribeAnomalyDetectors",
                  lambda: paginate(client, "describe_anomaly_detectors", "AnomalyDetectors"),
                  []
              )

              rr["contributor_insight_rules"] = len(rules)
              rr["anomaly_detectors"] = len(detectors)

              for d in detectors:
                  if d.get("SingleMetricAnomalyDetector"):
                      rr["single_metric_anomaly_detectors"] += 1
                  elif d.get("MetricMathAnomalyDetector"):
                      rr["metric_math_anomaly_detectors"] += 1

                  if settings["include_details"]:
                      single = d.get("SingleMetricAnomalyDetector") or {}
                      math = d.get("MetricMathAnomalyDetector") or {}
                      rr["anomaly_detector_inventory"].append({
                          "namespace": single.get("Namespace"),
                          "metric_name": single.get("MetricName"),
                          "dimensions": single.get("Dimensions"),
                          "stat": single.get("Stat"),
                          "configuration": d.get("Configuration"),
                          "metric_math_detector_present": bool(math)
                      })

              evaluated_for_action = 0

              for rule in rules:
                  name = rule.get("Name")
                  state = rule.get("State")
                  definition = rule.get("Definition")
                  is_managed = bool(rule.get("ManagedRule"))

                  if state == "ENABLED":
                      rr["enabled_contributor_insight_rules"] += 1
                  elif state == "DISABLED":
                      rr["disabled_contributor_insight_rules"] += 1

                  if is_managed:
                      rr["managed_contributor_insight_rules"] += 1
                  else:
                      rr["custom_contributor_insight_rules"] += 1

                  tags, tag_note = get_rule_tags(client, rr, rule)
                  if tag_note == "tag_arn_unavailable":
                      rr["tag_arn_unavailable"] += 1

                  item = {
                      "name": name,
                      "state": state,
                      "managed_rule": is_managed,
                      "definition_present": bool(definition),
                      "schema": rule.get("Schema"),
                      "tags": tags
                  }

                  action = "none"
                  reason = None

                  if state != "ENABLED":
                      rr["skipped_already_disabled"] += 1
                      action = "skipped_already_disabled"
                      reason = "Rule is not enabled"
                  elif not starts_with_any(name or "", settings["include_prefixes"]):
                      rr["skipped_include_prefix"] += 1
                      action = "skipped_include_prefix"
                      reason = "Rule name did not match include prefixes"
                  elif excluded_by_prefix(name or "", settings["exclude_prefixes"]):
                      rr["skipped_exclude_prefix"] += 1
                      action = "skipped_exclude_prefix"
                      reason = "Rule name matched exclude prefixes"
                  elif is_managed:
                      rr["skipped_managed_rule"] += 1
                      action = "skipped_managed_rule"
                      reason = "Managed Contributor Insights rule"
                  elif has_ignore_tag(tags, settings["ignore_tag_keys"], settings["ignore_tag_pairs"]):
                      rr["skipped_ignore_tag"] += 1
                      action = "skipped_ignore_tag"
                      reason = "Rule has ignore tag"
                  elif settings["require_approval_tag"] and not has_approval_tag(tags, settings["approval_tag_key"], settings["approval_tag_value"]):
                      rr["skipped_missing_approval_tag"] += 1
                      action = "skipped_missing_approval_tag"
                      reason = f"Missing {settings['approval_tag_key']}={settings['approval_tag_value']}"
                  else:
                      rr["eligible_rules"] += 1
                      action = "eligible"
                      reason = "Eligible for approved disable action"

                      if evaluated_for_action < settings["max_rules"]:
                          rr["would_disable_rules"] += 1
                          if settings["action_mode"] == "disable-approved-contributor-insights":
                              if settings["dry_run"]:
                                  action = "would_disable"
                              else:
                                  try:
                                      client.disable_insight_rules(RuleNames=[name])
                                      rr["disabled_rules_by_action"] += 1
                                      rr["disabled_rule_names"].append(name)
                                      action = "disabled"
                                  except ClientError as e:
                                      if is_access_denied(e):
                                          rr["access_denied"] += 1
                                      else:
                                          rr["failed"] += 1
                                      rr["failures"].append(error_record("CloudWatch", "DisableInsightRules", e))
                          evaluated_for_action += 1
                      else:
                          rr["skipped_max_rules"] += 1
                          action = "skipped_max_rules"
                          reason = "MaxRulesPerRun reached"

                  item["action"] = action
                  item["action_reason"] = reason

                  if settings["include_details"]:
                      rr["rule_inventory"].append(item)
                  if action != "none":
                      rr["candidates"].append(item)

              if rr["enabled_contributor_insight_rules"] > 0:
                  rr["review_flags"].append("enabled_contributor_insight_rules_present")
              if rr["anomaly_detectors"] > 0:
                  rr["review_flags"].append("anomaly_detectors_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")))

              settings = {
                  "dry_run": parse_bool(event.get("dry_run"), env_bool("DEFAULT_DRY_RUN", True)),
                  "action_mode": event.get("action_mode") or os.environ.get("DEFAULT_ACTION_MODE", "review-only"),
                  "require_approval_tag": parse_bool(event.get("require_approval_tag"), env_bool("REQUIRE_APPROVAL_TAG", True)),
                  "approval_tag_key": event.get("approval_tag_key") or os.environ.get("APPROVAL_TAG_KEY", "CostOptApproved"),
                  "approval_tag_value": event.get("approval_tag_value") or os.environ.get("APPROVAL_TAG_VALUE", "true"),
                  "include_prefixes": csv_list(event.get("include_rule_name_prefixes_csv") or os.environ.get("INCLUDE_RULE_NAME_PREFIXES_CSV", "")),
                  "exclude_prefixes": csv_list(event.get("exclude_rule_name_prefixes_csv") or os.environ.get("EXCLUDE_RULE_NAME_PREFIXES_CSV", "")),
                  "ignore_tag_keys": set(csv_list(event.get("ignore_tag_keys_csv") or os.environ.get("IGNORE_TAG_KEYS_CSV", ""))),
                  "ignore_tag_pairs": csv_pairs(event.get("ignore_tag_pairs_csv") or os.environ.get("IGNORE_TAG_PAIRS_CSV", "")),
                  "max_rules": int(event.get("max_rules_per_run") or os.environ.get("MAX_RULES_PER_RUN", "25")),
                  "include_details": parse_bool(event.get("include_details"), env_bool("INCLUDE_DETAILS", True))
              }

              summary = {
                  "started_at": utc_now().isoformat(),
                  "dry_run": settings["dry_run"],
                  "action_mode": settings["action_mode"],
                  "review_only": settings["action_mode"] == "review-only",
                  "require_approval_tag": settings["require_approval_tag"],
                  "approval_tag": f"{settings['approval_tag_key']}={settings['approval_tag_value']}",
                  "excluded_regions": sorted(list(excluded)),
                  "totals": {
                      "regions_scanned": 0,
                      "contributor_insight_rules": 0,
                      "enabled_contributor_insight_rules": 0,
                      "disabled_contributor_insight_rules": 0,
                      "managed_contributor_insight_rules": 0,
                      "custom_contributor_insight_rules": 0,
                      "anomaly_detectors": 0,
                      "single_metric_anomaly_detectors": 0,
                      "metric_math_anomaly_detectors": 0,
                      "eligible_rules": 0,
                      "would_disable_rules": 0,
                      "disabled_rules_by_action": 0,
                      "skipped_already_disabled": 0,
                      "skipped_include_prefix": 0,
                      "skipped_exclude_prefix": 0,
                      "skipped_managed_rule": 0,
                      "skipped_missing_approval_tag": 0,
                      "skipped_ignore_tag": 0,
                      "skipped_max_rules": 0,
                      "tag_read_access_denied": 0,
                      "tag_arn_unavailable": 0,
                      "regions_with_review_flags": 0,
                      "access_denied": 0,
                      "failed": 0
                  },
                  "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))

              total_keys = [k for k in summary["totals"].keys() if k not in ("regions_scanned", "regions_with_review_flags")]

              for region in regions:
                  rr = scan_region(region, settings)

                  for key in total_keys:
                      summary["totals"][key] += int(rr.get(key, 0) or 0)

                  if rr.get("review_flags"):
                      summary["totals"]["regions_with_review_flags"] += 1

                  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 CloudWatch Contributor Insights and Anomaly Detection Review Remediator.
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt ReviewFunction.Arn
          Id: CloudWatchInsightsAnomalyReviewTarget
          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
