AWSTemplateFormatVersion: '2010-09-09'
Description: >
  CloudWatch Observability Cost Review Remediator.
  Inventories CloudWatch alarms, dashboards, metric streams, Evidently projects,
  RUM app monitors, and Synthetics canaries across enabled Regions.
  Review-only by default. Optional action stops explicitly approved running canaries.

Parameters:
  ProjectName:
    Type: String
    Default: cloudwatch-observability-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
      - stop-approved-canaries
    Description: Action to perform. Review-only is the default.

  RequireApprovalTag:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Require the approval tag before stopping canaries.

  ApprovalTagKey:
    Type: String
    Default: CostOptApproved
    Description: Tag key required before stopping canaries.

  ApprovalTagValue:
    Type: String
    Default: 'true'
    Description: Tag value required before stopping canaries.

  MinimumCanaryAgeDays:
    Type: Number
    Default: 30
    MinValue: 0
    Description: Minimum canary age before it can be considered for stop action.

  IncludeCanaryNamePrefixesCsv:
    Type: String
    Default: ''
    Description: Optional comma-separated canary name prefixes to include.

  ExcludeCanaryNamePrefixesCsv:
    Type: String
    Default: ''
    Description: Optional comma-separated canary name prefixes to exclude.

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

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

  MaxCanariesPerRun:
    Type: Number
    Default: 25
    MinValue: 1
    Description: Maximum canaries to evaluate for action per run.

  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:
  RemediatorRole:
    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: CloudWatchObservabilityReviewPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: RegionDiscovery
                Effect: Allow
                Action:
                  - ec2:DescribeRegions
                Resource: '*'
              - Sid: CloudWatchRead
                Effect: Allow
                Action:
                  - cloudwatch:DescribeAlarms
                  - cloudwatch:ListDashboards
                  - cloudwatch:GetDashboard
                  - cloudwatch:ListMetricStreams
                  - cloudwatch:GetMetricStream
                Resource: '*'
              - Sid: SyntheticsRead
                Effect: Allow
                Action:
                  - synthetics:DescribeCanaries
                  - synthetics:DescribeCanariesLastRun
                  - synthetics:ListTagsForResource
                Resource: '*'
              - Sid: SyntheticsStopApproved
                Effect: Allow
                Action:
                  - synthetics:StopCanary
                Resource: '*'
              - Sid: EvidentlyRead
                Effect: Allow
                Action:
                  - evidently:ListProjects
                  - evidently:ListExperiments
                  - evidently:ListLaunches
                  - evidently:ListFeatures
                Resource: '*'
              - Sid: RumRead
                Effect: Allow
                Action:
                  - rum:ListAppMonitors
                  - rum:GetAppMonitor
                Resource: '*'

  RemediatorFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref ProjectName
      Description: Review CloudWatch observability cost artifacts and optionally stop approved Synthetics canaries.
      Runtime: python3.12
      Handler: index.handler
      Timeout: 900
      MemorySize: 512
      Role: !GetAtt RemediatorRole.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
          MINIMUM_CANARY_AGE_DAYS: !Ref MinimumCanaryAgeDays
          INCLUDE_CANARY_NAME_PREFIXES_CSV: !Ref IncludeCanaryNamePrefixesCsv
          EXCLUDE_CANARY_NAME_PREFIXES_CSV: !Ref ExcludeCanaryNamePrefixesCsv
          IGNORE_TAG_KEYS_CSV: !Ref IgnoreTagKeysCsv
          IGNORE_TAG_PAIRS_CSV: !Ref IgnoreTagPairsCsv
          MAX_CANARIES_PER_RUN: !Ref MaxCanariesPerRun
          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 csv_list_value(value):
              raw = value or ""
              return [x.strip() for x in raw.split(",") if x.strip()]

          def csv_set_env(name, default=""):
              return set(csv_list_value(os.environ.get(name, default)))

          def csv_pairs_env(name, default=""):
              pairs = {}
              for item in csv_list_value(os.environ.get(name, default)):
                  if "=" in item:
                      k, v = item.split("=", 1)
                      pairs[k.strip()] = v.strip()
              return pairs

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

          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 {}
              try:
                  paginator = client.get_paginator(operation)
                  items = []
                  for page in paginator.paginate(**kwargs):
                      items.extend(page.get(result_key, []) or [])
                  return items
              except Exception:
                  method = getattr(client, operation)
                  response = method(**kwargs)
                  return response.get(result_key, []) or []

          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 normalize_tags(tag_data):
              if not tag_data:
                  return {}
              if isinstance(tag_data, dict):
                  return tag_data
              if isinstance(tag_data, list):
                  out = {}
                  for item in tag_data:
                      k = item.get("Key") or item.get("key")
                      v = item.get("Value") or item.get("value")
                      if k:
                          out[k] = v
                  return out
              return {}

          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 age_days(dt):
              if not dt:
                  return None
              if isinstance(dt, str):
                  try:
                      dt = datetime.fromisoformat(dt.replace("Z", "+00:00"))
                  except Exception:
                      return None
              if dt.tzinfo is None:
                  dt = dt.replace(tzinfo=timezone.utc)
              return int((utc_now() - dt).total_seconds() // 86400)

          def scan_cloudwatch(region, rr):
              cw = boto3.client("cloudwatch", region_name=region)

              alarms = safe_call(rr, "CloudWatch", "DescribeAlarms",
                                 lambda: paginate(cw, "describe_alarms", "MetricAlarms"), [])
              composite = safe_call(rr, "CloudWatch", "DescribeAlarmsComposite",
                                    lambda: paginate(cw, "describe_alarms", "CompositeAlarms"), [])
              dashboards = safe_call(rr, "CloudWatch", "ListDashboards",
                                     lambda: paginate(cw, "list_dashboards", "DashboardEntries"), [])
              streams = safe_call(rr, "CloudWatch", "ListMetricStreams",
                                  lambda: paginate(cw, "list_metric_streams", "Entries"), [])

              rr["metric_alarms"] = len(alarms)
              rr["composite_alarms"] = len(composite)
              rr["dashboards"] = len(dashboards)
              rr["metric_streams"] = len(streams)
              rr["cloudwatch_inventory"] = {
                  "metric_alarm_sample": [
                      {
                          "alarm_name": x.get("AlarmName"),
                          "state_value": x.get("StateValue"),
                          "namespace": x.get("Namespace"),
                          "metric_name": x.get("MetricName"),
                          "period": x.get("Period"),
                          "evaluation_periods": x.get("EvaluationPeriods"),
                          "actions_enabled": x.get("ActionsEnabled")
                      } for x in alarms[:50]
                  ],
                  "composite_alarm_sample": [
                      {
                          "alarm_name": x.get("AlarmName"),
                          "state_value": x.get("StateValue"),
                          "actions_enabled": x.get("ActionsEnabled")
                      } for x in composite[:50]
                  ],
                  "dashboard_names": [x.get("DashboardName") for x in dashboards],
                  "metric_streams": [
                      {
                          "name": x.get("Name"),
                          "arn": x.get("Arn"),
                          "state": x.get("State"),
                          "creation_date": x.get("CreationDate"),
                          "last_update_date": x.get("LastUpdateDate")
                      } for x in streams
                  ]
              }

          def scan_synthetics(region, rr, settings):
              client = boto3.client("synthetics", region_name=region)
              canaries = safe_call(rr, "Synthetics", "DescribeCanaries",
                                   lambda: paginate(client, "describe_canaries", "Canaries"), [])
              last_runs = safe_call(rr, "Synthetics", "DescribeCanariesLastRun",
                                    lambda: paginate(client, "describe_canaries_last_run", "CanariesLastRun"), [])
              last_run_by_name = {x.get("CanaryName"): x for x in last_runs}

              rr["synthetics_canaries"] = len(canaries)
              inventory = []
              candidates = []
              evaluated_for_action = 0

              for c in canaries:
                  name = c.get("Name")
                  arn = c.get("Id") or c.get("Arn")
                  status = (c.get("Status") or {}).get("State")
                  created = c.get("Timeline", {}).get("Created")
                  canary_age = age_days(created)
                  last_run = last_run_by_name.get(name, {})
                  last_run_status = ((last_run.get("LastRun") or {}).get("Status") or {}).get("State")

                  tags = {}
                  if arn:
                      try:
                          tag_response = client.list_tags_for_resource(ResourceArn=arn)
                          tags = normalize_tags(tag_response.get("Tags"))
                      except ClientError as e:
                          if is_access_denied(e):
                              rr["tag_read_access_denied"] += 1
                          else:
                              rr["failed"] += 1
                          rr["failures"].append(error_record("Synthetics", "ListTagsForResource", e))

                  item = {
                      "name": name,
                      "arn": arn,
                      "runtime_version": c.get("RuntimeVersion"),
                      "status": status,
                      "created": created,
                      "age_days": canary_age,
                      "schedule_expression": c.get("Schedule", {}).get("Expression"),
                      "schedule_duration_in_seconds": c.get("Schedule", {}).get("DurationInSeconds"),
                      "artifact_s3_location": c.get("ArtifactS3Location"),
                      "last_run_status": last_run_status,
                      "tags": tags
                  }
                  inventory.append(item)

                  if status == "RUNNING":
                      rr["running_canaries"] += 1
                  elif status == "STOPPED":
                      rr["stopped_canaries"] += 1

                  action = "none"
                  reason = None

                  if not starts_with_any(name or "", settings["include_prefixes"]):
                      rr["skipped_include_prefix"] += 1
                      action = "skipped_include_prefix"
                      reason = "Canary 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 = "Canary name matched exclude prefixes"
                  elif status != "RUNNING":
                      rr["skipped_not_running"] += 1
                      action = "skipped_not_running"
                      reason = "Canary is not running"
                  elif canary_age is not None and canary_age < settings["minimum_age_days"]:
                      rr["skipped_too_new"] += 1
                      action = "skipped_too_new"
                      reason = "Canary is newer than minimum age"
                  elif has_ignore_tag(tags, settings["ignore_tag_keys"], settings["ignore_tag_pairs"]):
                      rr["skipped_ignore_tag"] += 1
                      action = "skipped_ignore_tag"
                      reason = "Canary 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_canaries"] += 1
                      action = "eligible"
                      reason = "Eligible for approved stop action"
                      if evaluated_for_action < settings["max_canaries"]:
                          rr["would_stop_canaries"] += 1
                          if settings["action_mode"] == "stop-approved-canaries":
                              if settings["dry_run"]:
                                  action = "would_stop"
                              else:
                                  try:
                                      client.stop_canary(Name=name)
                                      rr["stopped_canaries_by_action"] += 1
                                      rr["stopped_canary_names"].append(name)
                                      action = "stopped"
                                  except ClientError as e:
                                      if is_access_denied(e):
                                          rr["access_denied"] += 1
                                      else:
                                          rr["failed"] += 1
                                      rr["failures"].append(error_record("Synthetics", "StopCanary", e))
                          evaluated_for_action += 1
                      else:
                          rr["skipped_max_canaries"] += 1
                          action = "skipped_max_canaries"
                          reason = "MaxCanariesPerRun reached"

                  item["action"] = action
                  item["action_reason"] = reason
                  if action != "none":
                      candidates.append(item)

              rr["canary_inventory"] = inventory
              rr["candidates"] = candidates

          def scan_evidently(region, rr):
              # CloudWatch Evidently is not available in every Lambda boto3/botocore build.
              # Treat it as optional so the rest of the observability review still completes.
              available_services = boto3.Session().get_available_services()
              if "evidently" not in available_services:
                  rr["evidently_service_available"] = False
                  rr["evidently_inventory"] = []
                  return

              rr["evidently_service_available"] = True
              client = boto3.client("evidently", region_name=region)
              projects = safe_call(rr, "Evidently", "ListProjects",
                                   lambda: paginate(client, "list_projects", "projects"), [])
              rr["evidently_projects"] = len(projects)
              total_experiments = 0
              total_launches = 0
              total_features = 0
              inventory = []

              for p in projects:
                  name = p.get("name") or p.get("arn")
                  experiments = safe_call(rr, "Evidently", "ListExperiments",
                                          lambda n=name: paginate(client, "list_experiments", "experiments", {"project": n}), [])
                  launches = safe_call(rr, "Evidently", "ListLaunches",
                                       lambda n=name: paginate(client, "list_launches", "launches", {"project": n}), [])
                  features = safe_call(rr, "Evidently", "ListFeatures",
                                       lambda n=name: paginate(client, "list_features", "features", {"project": n}), [])
                  total_experiments += len(experiments)
                  total_launches += len(launches)
                  total_features += len(features)
                  inventory.append({
                      "name": p.get("name"),
                      "arn": p.get("arn"),
                      "status": p.get("status"),
                      "experiments": len(experiments),
                      "launches": len(launches),
                      "features": len(features)
                  })

              rr["evidently_experiments"] = total_experiments
              rr["evidently_launches"] = total_launches
              rr["evidently_features"] = total_features
              rr["evidently_inventory"] = inventory

          def scan_rum(region, rr):
              client = boto3.client("rum", region_name=region)
              monitors = safe_call(rr, "RUM", "ListAppMonitors",
                                   lambda: paginate(client, "list_app_monitors", "AppMonitorSummaries"), [])
              rr["rum_app_monitors"] = len(monitors)
              rr["rum_inventory"] = [
                  {
                      "name": x.get("Name"),
                      "id": x.get("Id"),
                      "state": x.get("State"),
                      "created": x.get("Created")
                  } for x in monitors
              ]

          def handler(event, context):
              event = event or {}
              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")
              excluded = set(csv_list_value(event.get("excluded_regions_csv") or os.environ.get("EXCLUDED_REGIONS_CSV", "ap-northeast-3")))

              settings = {
                  "dry_run": dry_run,
                  "action_mode": action_mode,
                  "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"),
                  "minimum_age_days": int(event.get("minimum_canary_age_days") or os.environ.get("MINIMUM_CANARY_AGE_DAYS", "30")),
                  "include_prefixes": csv_list_value(event.get("include_canary_name_prefixes_csv") or os.environ.get("INCLUDE_CANARY_NAME_PREFIXES_CSV", "")),
                  "exclude_prefixes": csv_list_value(event.get("exclude_canary_name_prefixes_csv") or os.environ.get("EXCLUDE_CANARY_NAME_PREFIXES_CSV", "")),
                  "ignore_tag_keys": set(csv_list_value(event.get("ignore_tag_keys_csv") or os.environ.get("IGNORE_TAG_KEYS_CSV", ""))),
                  "ignore_tag_pairs": csv_pairs_env("IGNORE_TAG_PAIRS_CSV", ""),
                  "max_canaries": int(event.get("max_canaries_per_run") or os.environ.get("MAX_CANARIES_PER_RUN", "25"))
              }

              summary = {
                  "started_at": utc_now().isoformat(),
                  "dry_run": dry_run,
                  "action_mode": action_mode,
                  "review_only": action_mode == "review-only",
                  "minimum_canary_age_days": settings["minimum_age_days"],
                  "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,
                      "metric_alarms": 0,
                      "composite_alarms": 0,
                      "dashboards": 0,
                      "metric_streams": 0,
                      "synthetics_canaries": 0,
                      "running_canaries": 0,
                      "stopped_canaries": 0,
                      "eligible_canaries": 0,
                      "would_stop_canaries": 0,
                      "stopped_canaries_by_action": 0,
                      "evidently_projects": 0,
                      "evidently_experiments": 0,
                      "evidently_launches": 0,
                      "evidently_features": 0,
                      "evidently_service_unavailable_regions": 0,
                      "rum_app_monitors": 0,
                      "skipped_not_running": 0,
                      "skipped_too_new": 0,
                      "skipped_include_prefix": 0,
                      "skipped_exclude_prefix": 0,
                      "skipped_missing_approval_tag": 0,
                      "skipped_ignore_tag": 0,
                      "skipped_max_canaries": 0,
                      "tag_read_access_denied": 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))

              for region in regions:
                  rr = {
                      "region": region,
                      "metric_alarms": 0,
                      "composite_alarms": 0,
                      "dashboards": 0,
                      "metric_streams": 0,
                      "synthetics_canaries": 0,
                      "running_canaries": 0,
                      "stopped_canaries": 0,
                      "eligible_canaries": 0,
                      "would_stop_canaries": 0,
                      "stopped_canaries_by_action": 0,
                      "evidently_projects": 0,
                      "evidently_experiments": 0,
                      "evidently_launches": 0,
                      "evidently_features": 0,
                      "evidently_service_unavailable_regions": 0,
                      "rum_app_monitors": 0,
                      "skipped_not_running": 0,
                      "skipped_too_new": 0,
                      "skipped_include_prefix": 0,
                      "skipped_exclude_prefix": 0,
                      "skipped_missing_approval_tag": 0,
                      "skipped_ignore_tag": 0,
                      "skipped_max_canaries": 0,
                      "tag_read_access_denied": 0,
                      "access_denied": 0,
                      "failed": 0,
                      "failures": [],
                      "stopped_canary_names": []
                  }

                  scan_cloudwatch(region, rr)
                  scan_synthetics(region, rr, settings)
                  scan_evidently(region, rr)
                  if rr.get("evidently_service_available") is False:
                      rr["evidently_service_unavailable_regions"] = 1
                  scan_rum(region, rr)

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

                  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))

  RemediatorScheduleRule:
    Type: AWS::Events::Rule
    Condition: IsScheduleEnabled
    Properties:
      Name: !Sub '${ProjectName}-schedule'
      Description: Scheduled run for CloudWatch Observability Cost Review Remediator.
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt RemediatorFunction.Arn
          Id: CloudWatchObservabilityReviewTarget
          Input: '{"source":"eventbridge-schedule"}'

  RemediatorSchedulePermission:
    Type: AWS::Lambda::Permission
    Condition: IsScheduleEnabled
    Properties:
      FunctionName: !Ref RemediatorFunction
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt RemediatorScheduleRule.Arn

Outputs:
  RemediatorFunctionName:
    Description: Lambda function name.
    Value: !Ref RemediatorFunction

  ReviewFunctionName:
    Description: Lambda function name alias for standard review workflows.
    Value: !Ref RemediatorFunction

  RemediatorFunctionArn:
    Description: Lambda function ARN.
    Value: !GetAtt RemediatorFunction.Arn
