diff --git a/terraform/environments/aws/compute.tf b/terraform/environments/aws/compute.tf index 680517c1a..123b19567 100644 --- a/terraform/environments/aws/compute.tf +++ b/terraform/environments/aws/compute.tf @@ -77,6 +77,12 @@ module "compute_lambda" { enable_ri_exchange_schedule = var.enable_ri_exchange_schedule ri_exchange_schedule = var.ri_exchange_schedule + # Commitment-ladder scheduling (default-off until GA) + enable_ladder_run_schedule = var.enable_ladder_run_schedule + ladder_run_schedule = var.ladder_run_schedule + enable_fire_scheduled_purchases_schedule = var.enable_fire_scheduled_purchases_schedule + fire_scheduled_purchases_schedule = var.fire_scheduled_purchases_schedule + # Additional environment variables additional_env_vars = merge( { @@ -187,6 +193,12 @@ module "compute_fargate" { enable_ri_exchange_schedule = var.enable_ri_exchange_schedule ri_exchange_schedule = var.ri_exchange_schedule + # Commitment-ladder scheduling (default-off until GA) + enable_ladder_run_schedule = var.enable_ladder_run_schedule + ladder_run_schedule = var.ladder_run_schedule + enable_fire_scheduled_purchases_schedule = var.enable_fire_scheduled_purchases_schedule + fire_scheduled_purchases_schedule = var.fire_scheduled_purchases_schedule + # ECS Exec for debugging enable_execute_command = var.fargate_enable_execute_command diff --git a/terraform/environments/aws/variables.tf b/terraform/environments/aws/variables.tf index a69171c46..696798c2b 100644 --- a/terraform/environments/aws/variables.tf +++ b/terraform/environments/aws/variables.tf @@ -288,6 +288,30 @@ variable "ri_exchange_schedule" { default = "rate(6 hours)" } +variable "enable_ladder_run_schedule" { + description = "Enable the scheduled commitment-ladder planning run. Default false until laddering is promoted to GA." + type = bool + default = false +} + +variable "ladder_run_schedule" { + description = "EventBridge schedule for the commitment-ladder planning run (e.g. rate(1 day) or cron(0 2 * * ? *) for 02:00 UTC)." + type = string + default = "rate(1 day)" +} + +variable "enable_fire_scheduled_purchases_schedule" { + description = "Enable the scheduled fire_scheduled_purchases sweep. Default false until auto-approve (L9) is promoted to GA." + type = bool + default = false +} + +variable "fire_scheduled_purchases_schedule" { + description = "EventBridge schedule for the fire_scheduled_purchases sweep (default rate(15 minutes))." + type = string + default = "rate(15 minutes)" +} + # ============================================== # Multi-Account Configuration # ============================================== diff --git a/terraform/modules/compute/aws/fargate/main.tf b/terraform/modules/compute/aws/fargate/main.tf index e7d347052..003abeb0d 100644 --- a/terraform/modules/compute/aws/fargate/main.tf +++ b/terraform/modules/compute/aws/fargate/main.tf @@ -975,3 +975,227 @@ resource "aws_iam_role_policy" "eventbridge_ri_exchange" { ] }) } + +# ============================================== +# Scheduled Commitment-Ladder Planning Run +# ============================================== +# +# The "ladder_run" scheduled action is registered by the ladder_run task +# (PR #1362). This rule is default-off (count-gated on +# var.enable_ladder_run_schedule). Enabling it before that task ships, or on +# an incomplete deploy, causes a loud dispatch error (unknown scheduled task +# action) rather than a silent no-op. + +resource "aws_cloudwatch_event_rule" "ladder_run" { + count = var.enable_ladder_run_schedule ? 1 : 0 + + name = "${local.name_prefix}-ladder-run" + description = "Trigger commitment-ladder planning run (ladder_run task)" + schedule_expression = var.ladder_run_schedule + + tags = local.common_tags +} + +resource "aws_cloudwatch_event_target" "ladder_run" { + count = var.enable_ladder_run_schedule ? 1 : 0 + + rule = aws_cloudwatch_event_rule.ladder_run[0].name + target_id = "ecs-task" + arn = aws_ecs_cluster.main.arn + role_arn = aws_iam_role.eventbridge_ladder_run[0].arn + + ecs_target { + task_count = 1 + task_definition_arn = aws_ecs_task_definition.main.arn + launch_type = "FARGATE" + platform_version = "LATEST" + + network_configuration { + subnets = var.private_subnet_ids + security_groups = [aws_security_group.ecs_tasks.id] + assign_public_ip = false + } + } + + retry_policy { + maximum_retry_attempts = 0 + maximum_event_age_in_seconds = 3600 + } + + input = jsonencode({ + containerOverrides = [{ + name = "app" + command = ["./cudly", "--task", "ladder_run"] + }] + }) +} + +resource "aws_iam_role" "eventbridge_ladder_run" { + count = var.enable_ladder_run_schedule ? 1 : 0 + + name = "${local.name_prefix}-eb-ladder-run" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + Service = "events.amazonaws.com" + } + Action = "sts:AssumeRole" + } + ] + }) + + tags = local.common_tags +} + +resource "aws_iam_role_policy" "eventbridge_ladder_run" { + count = var.enable_ladder_run_schedule ? 1 : 0 + + name = "ecs-run-task" + role = aws_iam_role.eventbridge_ladder_run[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "ecs:RunTask" + ] + Resource = aws_ecs_task_definition.main.arn + Condition = { + ArnEquals = { + "ecs:cluster" = aws_ecs_cluster.main.arn + } + } + }, + { + Effect = "Allow" + Action = [ + "iam:PassRole" + ] + Resource = [ + aws_iam_role.task_execution.arn, + aws_iam_role.task.arn + ] + Condition = { + StringEquals = { + "iam:PassedToService" = "ecs-tasks.amazonaws.com" + } + } + } + ] + }) +} + +# ============================================== +# Scheduled Fire-Scheduled-Purchases Sweep +# ============================================== + +resource "aws_cloudwatch_event_rule" "fire_scheduled_purchases" { + count = var.enable_fire_scheduled_purchases_schedule ? 1 : 0 + + name = "${local.name_prefix}-fire-scheduled-purchases" + description = "Trigger fire_scheduled_purchases sweep (auto-approve delayed execution)" + schedule_expression = var.fire_scheduled_purchases_schedule + + tags = local.common_tags +} + +resource "aws_cloudwatch_event_target" "fire_scheduled_purchases" { + count = var.enable_fire_scheduled_purchases_schedule ? 1 : 0 + + rule = aws_cloudwatch_event_rule.fire_scheduled_purchases[0].name + target_id = "ecs-task" + arn = aws_ecs_cluster.main.arn + role_arn = aws_iam_role.eventbridge_fire_scheduled_purchases[0].arn + + ecs_target { + task_count = 1 + task_definition_arn = aws_ecs_task_definition.main.arn + launch_type = "FARGATE" + platform_version = "LATEST" + + network_configuration { + subnets = var.private_subnet_ids + security_groups = [aws_security_group.ecs_tasks.id] + assign_public_ip = false + } + } + + retry_policy { + maximum_retry_attempts = 0 + maximum_event_age_in_seconds = 3600 + } + + input = jsonencode({ + containerOverrides = [{ + name = "app" + command = ["./cudly", "--task", "fire_scheduled_purchases"] + }] + }) +} + +resource "aws_iam_role" "eventbridge_fire_scheduled_purchases" { + count = var.enable_fire_scheduled_purchases_schedule ? 1 : 0 + + name = "${local.name_prefix}-eb-fire-sched-purchases" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + Service = "events.amazonaws.com" + } + Action = "sts:AssumeRole" + } + ] + }) + + tags = local.common_tags +} + +resource "aws_iam_role_policy" "eventbridge_fire_scheduled_purchases" { + count = var.enable_fire_scheduled_purchases_schedule ? 1 : 0 + + name = "ecs-run-task" + role = aws_iam_role.eventbridge_fire_scheduled_purchases[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "ecs:RunTask" + ] + Resource = aws_ecs_task_definition.main.arn + Condition = { + ArnEquals = { + "ecs:cluster" = aws_ecs_cluster.main.arn + } + } + }, + { + Effect = "Allow" + Action = [ + "iam:PassRole" + ] + Resource = [ + aws_iam_role.task_execution.arn, + aws_iam_role.task.arn + ] + Condition = { + StringEquals = { + "iam:PassedToService" = "ecs-tasks.amazonaws.com" + } + } + } + ] + }) +} diff --git a/terraform/modules/compute/aws/fargate/variables.tf b/terraform/modules/compute/aws/fargate/variables.tf index daa613078..bc269d539 100644 --- a/terraform/modules/compute/aws/fargate/variables.tf +++ b/terraform/modules/compute/aws/fargate/variables.tf @@ -190,6 +190,30 @@ variable "ri_exchange_schedule" { default = "rate(6 hours)" } +variable "enable_ladder_run_schedule" { + description = "Enable the scheduled commitment-ladder planning run. Default false until laddering is promoted to GA." + type = bool + default = false +} + +variable "ladder_run_schedule" { + description = "EventBridge schedule for the commitment-ladder planning run (e.g. rate(1 day) or cron(0 2 * * ? *) for 02:00 UTC)." + type = string + default = "rate(1 day)" +} + +variable "enable_fire_scheduled_purchases_schedule" { + description = "Enable the scheduled fire_scheduled_purchases sweep. Default false until auto-approve (L9) is promoted to GA." + type = bool + default = false +} + +variable "fire_scheduled_purchases_schedule" { + description = "EventBridge schedule for the fire_scheduled_purchases sweep (default rate(15 minutes))." + type = string + default = "rate(15 minutes)" +} + variable "task_timeout" { description = "Timeout in seconds for one-off scheduled tasks" type = number diff --git a/terraform/modules/compute/aws/lambda/main.tf b/terraform/modules/compute/aws/lambda/main.tf index 418fdceca..98d50f1c1 100644 --- a/terraform/modules/compute/aws/lambda/main.tf +++ b/terraform/modules/compute/aws/lambda/main.tf @@ -641,3 +641,95 @@ resource "aws_lambda_permission" "eventbridge_analytics_collect" { principal = "events.amazonaws.com" source_arn = aws_cloudwatch_event_rule.analytics_collect[0].arn } + +# ============================================== +# EventBridge Rule for Commitment-Ladder Planning Run +# ============================================== +# +# Daily tick that invokes the ladder_run scheduled task: reads the +# configured target coverage, computes the gap, and plans the next +# batch of SP/RI purchases (and reshapes once L16 lands). The +# handler is advisory-lock guarded, so overlapping invocations are +# safe. Default-off until laddering is promoted to GA; enable via +# var.enable_ladder_run_schedule. +# +# The "ladder_run" scheduled action is registered by the ladder_run task +# (PR #1362). This rule is default-off (count-gated). Enabling it before +# that task ships, or on an incomplete deploy, causes a loud dispatch error +# (unknown scheduled task action) rather than a silent no-op. + +resource "aws_cloudwatch_event_rule" "ladder_run" { + count = var.enable_ladder_run_schedule ? 1 : 0 + + name = "${var.stack_name}-ladder-run" + description = "Trigger commitment-ladder planning run (ladder_run task)" + schedule_expression = var.ladder_run_schedule + + tags = var.tags +} + +resource "aws_cloudwatch_event_target" "ladder_run" { + count = var.enable_ladder_run_schedule ? 1 : 0 + + rule = aws_cloudwatch_event_rule.ladder_run[0].name + target_id = "lambda" + arn = aws_lambda_function.main.arn + + input = jsonencode({ + action = "ladder_run" + }) +} + +resource "aws_lambda_permission" "eventbridge_ladder_run" { + count = var.enable_ladder_run_schedule ? 1 : 0 + + statement_id = "AllowExecutionFromEventBridgeLadderRun" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.main.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.ladder_run[0].arn +} + +# ============================================== +# EventBridge Rule for Scheduled-Purchase Fire Sweep +# ============================================== +# +# Frequent tick that invokes the fire_scheduled_purchases task: +# scans purchase_executions in status=scheduled whose +# scheduled_execution_at is in the past and fires each one. This +# sweep is the runtime mechanism that makes auto-approve delayed +# purchases actually execute; without it, scheduled tranches would +# never fire between daily ladder runs. Default-off until the +# auto-approve feature (L9) is promoted to GA. + +resource "aws_cloudwatch_event_rule" "fire_scheduled_purchases" { + count = var.enable_fire_scheduled_purchases_schedule ? 1 : 0 + + name = "${var.stack_name}-fire-scheduled-purchases" + description = "Trigger fire_scheduled_purchases sweep (auto-approve delayed execution)" + schedule_expression = var.fire_scheduled_purchases_schedule + + tags = var.tags +} + +resource "aws_cloudwatch_event_target" "fire_scheduled_purchases" { + count = var.enable_fire_scheduled_purchases_schedule ? 1 : 0 + + rule = aws_cloudwatch_event_rule.fire_scheduled_purchases[0].name + target_id = "lambda" + arn = aws_lambda_function.main.arn + + input = jsonencode({ + action = "fire_scheduled_purchases" + }) +} + +resource "aws_lambda_permission" "eventbridge_fire_scheduled_purchases" { + count = var.enable_fire_scheduled_purchases_schedule ? 1 : 0 + + statement_id = "AllowExecutionFromEventBridgeFireScheduledPurchases" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.main.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.fire_scheduled_purchases[0].arn +} diff --git a/terraform/modules/compute/aws/lambda/variables.tf b/terraform/modules/compute/aws/lambda/variables.tf index d17d9acd2..587be104b 100644 --- a/terraform/modules/compute/aws/lambda/variables.tf +++ b/terraform/modules/compute/aws/lambda/variables.tf @@ -195,6 +195,30 @@ variable "analytics_collect_schedule" { default = "rate(1 day)" } +variable "enable_ladder_run_schedule" { + description = "Enable the scheduled commitment-ladder planning run. When true, EventBridge fires the ladder_run task daily. Default false until laddering is promoted to GA." + type = bool + default = false +} + +variable "ladder_run_schedule" { + description = "EventBridge schedule for the commitment-ladder planning run. rate() starts from deployment time; use cron() for a fixed daily window (e.g. cron(0 2 * * ? *) for 02:00 UTC). Daily cadence balances coverage freshness with CE API cost." + type = string + default = "rate(1 day)" +} + +variable "enable_fire_scheduled_purchases_schedule" { + description = "Enable the scheduled fire_scheduled_purchases sweep. When true, EventBridge periodically fires purchase_executions in status=scheduled whose scheduled_execution_at is past (auto-approve delayed-fire path). Default false until auto-approve (L9) is promoted to GA." + type = bool + default = false +} + +variable "fire_scheduled_purchases_schedule" { + description = "EventBridge schedule for the fire_scheduled_purchases sweep. Frequent cadence (default rate(15 minutes)) ensures delayed purchases fire close to their scheduled time between daily ladder runs." + type = string + default = "rate(15 minutes)" +} + variable "purchase_approved_reap_after" { description = "Threshold age for the stuck-purchase reaper. Any execution sitting in approved/running longer than this gets flipped to failed on the next sweep. Parsed via Go time.ParseDuration (e.g. \"10m\", \"15m\", \"1h\"). Empty string falls back to the in-code default (10m)." type = string