From 062cfa0e272402988b90c38053f1c3271d1760dc Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 16 Jul 2026 12:49:08 +0300 Subject: [PATCH 1/2] feat(terraform): EventBridge ticks for ladder_run and fire_scheduled_purchases (G9) Add two default-off EventBridge rules cloned from the ri_exchange pattern: - aws_cloudwatch_event_rule.ladder_run / target / permission, gated count = var.enable_ladder_run_schedule ? 1 : 0, fires action "ladder_run" on var.ladder_run_schedule (default rate(1 day)). - aws_cloudwatch_event_rule.fire_scheduled_purchases / target / permission, gated count = var.enable_fire_scheduled_purchases_schedule ? 1 : 0, fires action "fire_scheduled_purchases" on var.fire_scheduled_purchases_schedule (default rate(15 minutes)). Four new module vars (enable_ladder_run_schedule, ladder_run_schedule, enable_fire_scheduled_purchases_schedule, fire_scheduled_purchases_schedule) threaded through environments/aws, azure, gcp (variables.tf + compute.tf). All four default to off/false; no existing resource is modified. No new IAM: each block reuses the EventBridge->Lambda invoke-permission pattern already used by ri_exchange, reap_stuck_purchases, and analytics_collect. No bootstrap PR needed. Verified action strings against internal/server/handler.go scheduledEventActions map: "fire_scheduled_purchases" maps to TaskFireScheduledPurchases (line 71). "ladder_run" is not yet in the map (pending PR #1362); the rule is default-off and safe to add ahead of the dispatcher wiring. Gates: terraform fmt -check -recursive (exit 0), terraform validate in terraform/modules/compute/aws/lambda with -backend=false init (exit 0). --- terraform/environments/aws/compute.tf | 12 ++ terraform/environments/aws/variables.tf | 24 +++ terraform/modules/compute/aws/fargate/main.tf | 198 ++++++++++++++++++ .../modules/compute/aws/fargate/variables.tf | 24 +++ terraform/modules/compute/aws/lambda/main.tf | 87 ++++++++ .../modules/compute/aws/lambda/variables.tf | 24 +++ 6 files changed, 369 insertions(+) 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..93a92f6d9 100644 --- a/terraform/modules/compute/aws/fargate/main.tf +++ b/terraform/modules/compute/aws/fargate/main.tf @@ -975,3 +975,201 @@ resource "aws_iam_role_policy" "eventbridge_ri_exchange" { ] }) } + +# ============================================== +# Scheduled Commitment-Ladder Planning Run +# ============================================== + +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 + }, + { + Effect = "Allow" + Action = [ + "iam:PassRole" + ] + Resource = [ + aws_iam_role.task_execution.arn, + aws_iam_role.task.arn + ] + } + ] + }) +} + +# ============================================== +# 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 + }, + { + Effect = "Allow" + Action = [ + "iam:PassRole" + ] + Resource = [ + aws_iam_role.task_execution.arn, + aws_iam_role.task.arn + ] + } + ] + }) +} 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..964e5710a 100644 --- a/terraform/modules/compute/aws/lambda/main.tf +++ b/terraform/modules/compute/aws/lambda/main.tf @@ -641,3 +641,90 @@ 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. + +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 From 6d2ea515db1faafb0c969e9ee3e6648282e09106 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 16 Jul 2026 13:16:42 +0300 Subject: [PATCH 2/2] fix(terraform): least-privilege IAM conditions + dispatch-state docs on ladder ticks Address CodeRabbit findings on the L12 EventBridge ticks: - Tighten the two new Fargate EventBridge->ECS roles (ladder_run, fire_scheduled_purchases). Add ArnEquals ecs:cluster = aws_ecs_cluster.main.arn on the ecs:RunTask statement and StringEquals iam:PassedToService = "ecs-tasks.amazonaws.com" on the iam:PassRole statement, matching AWS best practice for EventBridge-triggered ECS tasks. Only the two new blocks are touched; the existing ri_exchange block is unchanged. - Document the intentional dispatch state above the ladder_run rule in both lambda/main.tf and fargate/main.tf: the "ladder_run" scheduled action is registered by the ladder_run task (PR #1362); the rule is default-off (count-gated) and enabling it before that task ships causes a loud dispatch error, never a silent no-op. No handler.go change here (that is #1362 scope; this PR stays terraform-only + default-off). --- terraform/modules/compute/aws/fargate/main.tf | 26 +++++++++++++++++++ terraform/modules/compute/aws/lambda/main.tf | 5 ++++ 2 files changed, 31 insertions(+) diff --git a/terraform/modules/compute/aws/fargate/main.tf b/terraform/modules/compute/aws/fargate/main.tf index 93a92f6d9..003abeb0d 100644 --- a/terraform/modules/compute/aws/fargate/main.tf +++ b/terraform/modules/compute/aws/fargate/main.tf @@ -979,6 +979,12 @@ 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 @@ -1060,6 +1066,11 @@ resource "aws_iam_role_policy" "eventbridge_ladder_run" { "ecs:RunTask" ] Resource = aws_ecs_task_definition.main.arn + Condition = { + ArnEquals = { + "ecs:cluster" = aws_ecs_cluster.main.arn + } + } }, { Effect = "Allow" @@ -1070,6 +1081,11 @@ resource "aws_iam_role_policy" "eventbridge_ladder_run" { aws_iam_role.task_execution.arn, aws_iam_role.task.arn ] + Condition = { + StringEquals = { + "iam:PassedToService" = "ecs-tasks.amazonaws.com" + } + } } ] }) @@ -1159,6 +1175,11 @@ resource "aws_iam_role_policy" "eventbridge_fire_scheduled_purchases" { "ecs:RunTask" ] Resource = aws_ecs_task_definition.main.arn + Condition = { + ArnEquals = { + "ecs:cluster" = aws_ecs_cluster.main.arn + } + } }, { Effect = "Allow" @@ -1169,6 +1190,11 @@ resource "aws_iam_role_policy" "eventbridge_fire_scheduled_purchases" { 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/lambda/main.tf b/terraform/modules/compute/aws/lambda/main.tf index 964e5710a..98d50f1c1 100644 --- a/terraform/modules/compute/aws/lambda/main.tf +++ b/terraform/modules/compute/aws/lambda/main.tf @@ -652,6 +652,11 @@ resource "aws_lambda_permission" "eventbridge_analytics_collect" { # 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