From 3499ad8a3ba998fb909e8293c463db36e7841faf Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 16 Oct 2026 09:54:01 +0200 Subject: [PATCH 1/4] fix(lambda): schedule process, notification and finalize sweeps Add sweeps.tf with a sweep_tasks map driving one EventBridge rule, target and invoke permission per active task. finalize_revocations is on by default; process_scheduled_purchases and send_notifications are opt-in. Rule names are -sweep- and stay within the 64-char limit up to a 48-char stack_name. A terraform test covers defaults, opt-in, name length and uniqueness, input and schedule. The coverage test now reads sweep_tasks keys as wired tasks. Part of #712 Co-Authored-By: Claude Opus 5.5 --- .../modules/compute/aws/lambda/sweeps.tf | 45 +++++++ .../aws/lambda/tests/sweeps.tftest.hcl | 110 ++++++++++++++++++ .../modules/compute/aws/lambda/variables.tf | 38 +++++- .../compute/scheduled_task_coverage_test.go | 28 +++-- .../compute/scheduled_task_env_test.go | 50 +++++--- 5 files changed, 250 insertions(+), 21 deletions(-) create mode 100644 terraform/modules/compute/aws/lambda/sweeps.tf create mode 100644 terraform/modules/compute/aws/lambda/tests/sweeps.tftest.hcl diff --git a/terraform/modules/compute/aws/lambda/sweeps.tf b/terraform/modules/compute/aws/lambda/sweeps.tf new file mode 100644 index 000000000..6f543fb1a --- /dev/null +++ b/terraform/modules/compute/aws/lambda/sweeps.tf @@ -0,0 +1,45 @@ +# Additional scheduled sweeps. Each entry becomes an EventBridge rule that +# invokes the server task named by the map key. `rule` is the short rule-name +# suffix: rule names are "-sweep-", so the longest suffix +# (at most 15 chars) fits the 64-char EventBridge limit; the module's stack_name +# maximum (39) is set by the longer "-fire-scheduled-purchases" rule in main.tf. +# The "sweep-" infix keeps these names clear of the older per-task rules. +# Retries stay at the EventBridge default: a re-run is safe under the per-task advisory lock and row CAS in the server. +locals { + sweep_tasks = { + process_scheduled_purchases = { rule = "process", enabled = var.enable_process_scheduled_purchases_schedule, schedule = var.process_scheduled_purchases_schedule } + send_notifications = { rule = "notify", enabled = var.enable_send_notifications_schedule, schedule = var.send_notifications_schedule } + finalize_revocations = { rule = "finalize", enabled = var.enable_finalize_revocations_schedule, schedule = var.finalize_revocations_schedule } + } + active_sweeps = { for k, v in local.sweep_tasks : k => v if v.enabled } +} + +resource "aws_cloudwatch_event_rule" "sweep" { + for_each = local.active_sweeps + + name = "${var.stack_name}-sweep-${each.value.rule}" + description = "Trigger the ${each.key} scheduled task" + schedule_expression = each.value.schedule + + tags = var.tags +} + +resource "aws_cloudwatch_event_target" "sweep" { + for_each = local.active_sweeps + + rule = aws_cloudwatch_event_rule.sweep[each.key].name + target_id = "lambda" + arn = aws_lambda_function.main.arn + + input = jsonencode({ action = each.key }) +} + +resource "aws_lambda_permission" "sweep" { + for_each = local.active_sweeps + + statement_id = "AllowExecutionFromEventBridge-${each.key}" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.main.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.sweep[each.key].arn +} diff --git a/terraform/modules/compute/aws/lambda/tests/sweeps.tftest.hcl b/terraform/modules/compute/aws/lambda/tests/sweeps.tftest.hcl new file mode 100644 index 000000000..3a8ca4af3 --- /dev/null +++ b/terraform/modules/compute/aws/lambda/tests/sweeps.tftest.hcl @@ -0,0 +1,110 @@ +# Module wiring for the sweep rules. Cross-wiring (rule -> target -> permission +# keys) cannot be asserted here on Terraform 1.10 (override_resource needs +# 1.11); the Go test in terraform/modules/compute covers it. +mock_provider "aws" {} + +variables { + # Worst case for the 64-char EventBridge rule-name limit. + stack_name = "cudly-production-abcd1234" + permissions_boundary_arn = "arn:aws:iam::123456789012:policy/cudly-boundary" + environment = "production" + region = "us-east-1" + image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/cudly:test" + database_host = "db.example.com" + database_name = "cudly" + database_username = "cudly" + database_password_secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:db" + admin_email = "admin@example.com" + allowed_origins = ["https://app.example.com"] +} + +run "defaults" { + command = plan + + assert { + condition = toset(keys(aws_cloudwatch_event_rule.sweep)) == toset(["finalize_revocations"]) + error_message = "Only finalize_revocations is a default-on sweep." + } + assert { + condition = length(aws_cloudwatch_event_rule.scheduled_recommendations) == 1 && length(aws_cloudwatch_event_rule.reap_stuck_purchases) == 1 && length(aws_cloudwatch_event_rule.analytics_collect) == 1 + error_message = "collect, reap and analytics_collect stay default-on." + } + assert { + condition = length(aws_cloudwatch_event_target.reap_stuck_purchases) == 1 && length(aws_lambda_permission.eventbridge_reap_stuck_purchases) == 1 && length(aws_cloudwatch_event_target.analytics_collect) == 1 && length(aws_lambda_permission.eventbridge_analytics_collect) == 1 + error_message = "reap and analytics_collect need their target and permission too." + } + assert { + condition = length(aws_cloudwatch_event_rule.fire_scheduled_purchases) == 0 && length(aws_cloudwatch_event_rule.ladder_run) == 0 && length(aws_cloudwatch_event_rule.ri_exchange) == 0 + error_message = "fire, ladder_run and ri_exchange stay opt-in." + } + assert { + condition = alltrue([ + for k, r in aws_cloudwatch_event_rule.sweep : + length(r.name) <= 64 && startswith(r.name, "cudly-") && endswith(r.name, "-sweep-${ { process_scheduled_purchases = "process", send_notifications = "notify", finalize_revocations = "finalize" }[k]}") + ]) + error_message = "Rule names must be <= 64 chars, keep the cudly- prefix and end with -sweep-." + } + assert { + condition = length(distinct([for r in aws_cloudwatch_event_rule.sweep : r.name])) == length(aws_cloudwatch_event_rule.sweep) + error_message = "Sweep rule names must be distinct." + } + assert { + condition = length(aws_cloudwatch_event_target.sweep) == 1 && length(aws_lambda_permission.sweep) == 1 + error_message = "Each active sweep needs a target and a permission." + } + assert { + condition = aws_cloudwatch_event_rule.sweep["finalize_revocations"].schedule_expression == var.finalize_revocations_schedule + error_message = "Schedule must come from the variable." + } + assert { + condition = aws_cloudwatch_event_target.sweep["finalize_revocations"].input == jsonencode({ action = "finalize_revocations" }) + error_message = "Target input must name the task." + } +} + +run "opt_in" { + command = plan + + variables { + enable_process_scheduled_purchases_schedule = true + enable_send_notifications_schedule = true + enable_fire_scheduled_purchases_schedule = true + } + + assert { + condition = toset(keys(aws_cloudwatch_event_rule.sweep)) == toset(["finalize_revocations", "process_scheduled_purchases", "send_notifications"]) + error_message = "Opting in must add process and send_notifications." + } + assert { + condition = length(aws_cloudwatch_event_rule.fire_scheduled_purchases) == 1 + error_message = "Fire stays on its own resource and appears when enabled." + } + assert { + condition = alltrue([ + for k, r in aws_cloudwatch_event_rule.sweep : + length(r.name) <= 64 && startswith(r.name, "cudly-") && endswith(r.name, "-sweep-${ { process_scheduled_purchases = "process", send_notifications = "notify", finalize_revocations = "finalize" }[k]}") + ]) + error_message = "Rule names must be <= 64 chars, keep the cudly- prefix and end with -sweep-." + } + assert { + condition = length(distinct([for r in aws_cloudwatch_event_rule.sweep : r.name])) == length(aws_cloudwatch_event_rule.sweep) + error_message = "Sweep rule names must be distinct." + } + assert { + condition = length(aws_cloudwatch_event_target.sweep) == 3 + error_message = "Every opted-in sweep needs a target." + } + assert { + condition = alltrue([ + for k, r in aws_cloudwatch_event_rule.sweep : + r.schedule_expression == { process_scheduled_purchases = var.process_scheduled_purchases_schedule, send_notifications = var.send_notifications_schedule, finalize_revocations = var.finalize_revocations_schedule }[k] + ]) + error_message = "Schedule must come from the variable." + } + assert { + condition = alltrue([ + for k, t in aws_cloudwatch_event_target.sweep : t.input == jsonencode({ action = k }) + ]) + error_message = "Each target input must name its task." + } +} diff --git a/terraform/modules/compute/aws/lambda/variables.tf b/terraform/modules/compute/aws/lambda/variables.tf index 908cc2cea..782a1b5a5 100644 --- a/terraform/modules/compute/aws/lambda/variables.tf +++ b/terraform/modules/compute/aws/lambda/variables.tf @@ -1,5 +1,5 @@ variable "stack_name" { - description = "Name of the stack" + description = "Name of the stack. At most 39 chars: the longest rule suffix is \"-fire-scheduled-purchases\" (25 chars) and EventBridge caps rule names at 64. The sweep rules (\"-sweep-\", up to 15 chars) fit well inside that." type = string } @@ -231,6 +231,42 @@ variable "fire_scheduled_purchases_schedule" { default = "rate(15 minutes)" } +variable "enable_process_scheduled_purchases_schedule" { + description = "Enable the scheduled process_scheduled_purchases sweep (proposes purchases that are due). Opt-in: default false so no purchase proposal runs unless an operator asks for it. Note (#894): the stuck-purchase reaper can fail stranded approved rows before process re-drives them." + type = bool + default = false +} + +variable "process_scheduled_purchases_schedule" { + description = "EventBridge schedule for the process_scheduled_purchases sweep." + type = string + default = "rate(1 hour)" +} + +variable "enable_send_notifications_schedule" { + description = "Enable the scheduled send_notifications sweep. Opt-in: default false; enable it together with process_scheduled_purchases." + type = bool + default = false +} + +variable "send_notifications_schedule" { + description = "EventBridge schedule for the send_notifications sweep." + type = string + default = "rate(1 day)" +} + +variable "enable_finalize_revocations_schedule" { + description = "Enable the scheduled finalize_revocations sweep (idempotent audit repair of revoked purchases)." + type = bool + default = true +} + +variable "finalize_revocations_schedule" { + description = "EventBridge schedule for the finalize_revocations sweep." + type = string + default = "rate(1 hour)" +} + 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 diff --git a/terraform/modules/compute/scheduled_task_coverage_test.go b/terraform/modules/compute/scheduled_task_coverage_test.go index daea04be9..52a098220 100644 --- a/terraform/modules/compute/scheduled_task_coverage_test.go +++ b/terraform/modules/compute/scheduled_task_coverage_test.go @@ -40,11 +40,12 @@ const ( ) var ( - validDims = []string{dimTask, dimPass, dimDefault} - allEight = []string{"analytics_collect", "cleanup", "collect_recommendations", "finalize_revocations", + validDims = []string{dimTask, dimPass, dimDefault} + lambdaEnvGaps = []string{"analytics_collect", "finalize_revocations", "process_scheduled_purchases", + "reap_stuck_purchases", "send_notifications"} + allEight = []string{"analytics_collect", "cleanup", "collect_recommendations", "finalize_revocations", "fire_scheduled_purchases", "process_scheduled_purchases", "reap_stuck_purchases", "send_notifications"} - reapAnalytics = []string{"analytics_collect", "reap_stuck_purchases"} - sevenGCP = []string{"analytics_collect", "cleanup", "finalize_revocations", "fire_scheduled_purchases", + sevenGCP = []string{"analytics_collect", "cleanup", "finalize_revocations", "fire_scheduled_purchases", "process_scheduled_purchases", "reap_stuck_purchases", "send_notifications"} ) @@ -54,9 +55,9 @@ var ( // task and are not scanned, so cleanup shows as a gap on AWS and GCP. var knownGaps = map[string]map[string][]string{ "aws-lambda": { - dimTask: {"cleanup", "finalize_revocations", "process_scheduled_purchases", "send_notifications"}, - dimPass: reapAnalytics, - dimDefault: reapAnalytics, + dimTask: {"cleanup"}, + dimPass: lambdaEnvGaps, + dimDefault: lambdaEnvGaps, }, "aws-fargate": {dimTask: {"analytics_collect", "cleanup", "finalize_revocations", "process_scheduled_purchases", "reap_stuck_purchases", "send_notifications"}}, @@ -135,6 +136,9 @@ var ( localRef = regexp.MustCompile(`\blocal\.(\w+)`) // requestAttr is the single-line attribute that carries the request target. requestAttr = regexp.MustCompile(`(?m)^\s*(?:uri|input|args)\s*=.*$`) + // sweepKeys matches the task keys of a `sweep_tasks` map: `for_each` targets + // carry `action = each.key`, so the literal is the map key. + sweepKeyRe = regexp.MustCompile(`(?m)^\s*(\w+)\s*=\s*\{`) // literalRes are the task-name patterns of the contract test (schedulerRefs) // plus the CloudFormation Input JSON. literalRes = func() []*regexp.Regexp { @@ -217,6 +221,15 @@ func wiredFrom(files map[string]string) (wired map[string]bool, unknown []string } all := strings.Join(stripped, "\n") for _, src := range stripped { + for _, body := range blocks(src, `\bsweep_tasks\s*=\s*\{`) { + for _, m := range sweepKeyRe.FindAllStringSubmatch(body, -1) { + if _, err := server.ParseScheduledTaskType(m[1]); err != nil { + unknown = append(unknown, m[1]) + } else { + wired[m[1]] = true + } + } + } for _, scope := range schedulerScopes(src, all) { for _, re := range literalRes { for _, m := range re.FindAllStringSubmatch(scope, -1) { @@ -372,6 +385,7 @@ func TestCheckerNegatives(t *testing.T) { "job uri resolved through a local": {aud + job("uri = local.aud"), []string{"collect_recommendations"}, nil}, "literal outside a scheduler block": {"resource \"aws_lambda_permission\" \"p\" {\n" + finalize + "\n}\n", nil, nil}, "unknown task in a scheduler block": {target(`input = jsonencode({ action = "bogus_task" })`), nil, []string{"bogus_task"}}, + "sweep_tasks keys are wired": {"locals {\n sweep_tasks = {\n finalize_revocations = { rule = \"finalize\" }\n bogus_task = { rule = \"x\" }\n }\n}\n", []string{"finalize_revocations"}, []string{"bogus_task"}}, "CloudFormation rule input": {cfn("AWS::Events::Rule"), []string{"finalize_revocations"}, nil}, "CloudFormation literal outside a rule": {cfn("AWS::Lambda::Function"), nil, nil}, } { diff --git a/terraform/modules/compute/scheduled_task_env_test.go b/terraform/modules/compute/scheduled_task_env_test.go index a0d644a72..0213969bb 100644 --- a/terraform/modules/compute/scheduled_task_env_test.go +++ b/terraform/modules/compute/scheduled_task_env_test.go @@ -135,14 +135,32 @@ func sweepProblems(dir string, needPermission bool) ([]string, error) { } src := stripComments(string(raw)) var problems []string - check := func(typ, attr, ref string) { + forEach := regexp.MustCompile(`(?m)^\s*for_each\s*=\s*local\.active_sweeps\s*$`) + action := regexp.MustCompile(`action\s*=\s*each\.key\b`) + check := func(typ, attr, ref string, extra ...*regexp.Regexp) { body, ok := blockOnce(src, `resource\s+"`+typ+`"\s+"sweep"\s*\{`) + if !ok { + problems = append(problems, typ+".sweep is missing or duplicated") + return + } + if !forEach.MatchString(body) { + problems = append(problems, typ+".sweep lacks for_each = local.active_sweeps") + } + for _, re := range extra { + if !re.MatchString(body) { + problems = append(problems, typ+".sweep lacks "+re.String()) + } + } + if attr == "" { + return + } want := regexp.MustCompile(`(?m)^\s*` + attr + `\s*=\s*aws_cloudwatch_event_rule\.sweep\[each\.key\]\.` + ref + `\b`) - if !ok || !want.MatchString(body) { - problems = append(problems, typ+".sweep is missing or lacks "+attr+" = aws_cloudwatch_event_rule.sweep[each.key]."+ref) + if !want.MatchString(body) { + problems = append(problems, typ+".sweep lacks "+attr+" = aws_cloudwatch_event_rule.sweep[each.key]."+ref) } } - check("aws_cloudwatch_event_target", "rule", "name") + check("aws_cloudwatch_event_rule", "", "") + check("aws_cloudwatch_event_target", "rule", "name", action) if needPermission { check("aws_lambda_permission", "source_arn", "arn") } @@ -281,21 +299,27 @@ func TestEnvChecksNegatives(t *testing.T) { } func TestSweepCrossWiring(t *testing.T) { - const target = "resource \"aws_cloudwatch_event_target\" \"sweep\" {\n rule%s= aws_cloudwatch_event_rule.sweep[%s].name\n}\n" - const perm = "resource \"aws_lambda_permission\" \"sweep\" {\n source_arn%s= aws_cloudwatch_event_rule.sweep[%s].arn\n}\n" + const rule = "resource \"aws_cloudwatch_event_rule\" \"sweep\" {\n for_each = local.active_sweeps\n}\n" + const target = "resource \"aws_cloudwatch_event_target\" \"sweep\" {\n for_each = local.active_sweeps\n rule%s= aws_cloudwatch_event_rule.sweep[%s].name\n input = jsonencode({ action = each.key })\n}\n" + const perm = "resource \"aws_lambda_permission\" \"sweep\" {\n for_each = local.active_sweeps\n source_arn%s= aws_cloudwatch_event_rule.sweep[%s].arn\n}\n" good, goodPerm := fmt.Sprintf(target, " ", "each.key"), fmt.Sprintf(perm, " ", "each.key") for name, c := range map[string]struct { src string lambda bool wantProb bool }{ - "aligned": {fmt.Sprintf(target, " ", "each.key") + fmt.Sprintf(perm, " ", "each.key"), true, false}, - "unaligned": {good + goodPerm, true, false}, - "fargate needs no perm": {good, false, false}, - "wrong key": {fmt.Sprintf(target, " ", "each.value.rule") + goodPerm, true, true}, - "wrong resource type": {strings.Replace(good, "event_rule", "event_target", 1) + goodPerm, true, true}, - "missing permission": {good, true, true}, - "missing target": {goodPerm, true, true}, + "aligned": {rule + fmt.Sprintf(target, " ", "each.key") + fmt.Sprintf(perm, " ", "each.key"), true, false}, + "unaligned": {rule + good + goodPerm, true, false}, + "fargate needs no perm": {rule + good, false, false}, + "wrong key": {rule + fmt.Sprintf(target, " ", "each.value.rule") + goodPerm, true, true}, + "wrong resource type": {rule + strings.Replace(good, "event_rule", "event_target", 1) + goodPerm, true, true}, + "missing permission": {rule + good, true, true}, + "missing target": {rule + goodPerm, true, true}, + "missing rule": {good + goodPerm, true, true}, + "rule not for_each": {strings.Replace(rule, "local.active_sweeps", "{}", 1) + good + goodPerm, true, true}, + "target not for_each": {rule + strings.Replace(good, "local.active_sweeps", "{}", 1) + goodPerm, true, true}, + "permission not for_each": {rule + good + strings.Replace(goodPerm, "local.active_sweeps", "{}", 1), true, true}, + "target action not key": {rule + strings.Replace(good, "action = each.key", "action = each.value.rule", 1) + goodPerm, true, true}, } { got, err := sweepProblems(writeFiles(t, map[string]string{"sweeps.tf": c.src}), c.lambda) if err != nil || (len(got) > 0) != c.wantProb { From b64af6e2e901a1494f4d7d189edea8f13ededc45 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 16 Oct 2026 09:54:37 +0200 Subject: [PATCH 2/4] fix(aws): pass Lambda sweep schedules through the environment Add enable/schedule variables for process, notifications, finalize, reap and analytics collection to the AWS environment and pass them to compute_lambda. Process and notifications default off; the others default on. Drops the Lambda env pass-through and default gaps from the coverage test. Part of #712 Co-Authored-By: Claude Opus 5.5 --- terraform/environments/aws/compute.tf | 12 ++++ terraform/environments/aws/variables.tf | 60 +++++++++++++++++++ .../compute/scheduled_task_coverage_test.go | 12 +--- 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/terraform/environments/aws/compute.tf b/terraform/environments/aws/compute.tf index c38f6c968..e87f573f0 100644 --- a/terraform/environments/aws/compute.tf +++ b/terraform/environments/aws/compute.tf @@ -84,6 +84,18 @@ module "compute_lambda" { enable_fire_scheduled_purchases_schedule = var.enable_fire_scheduled_purchases_schedule fire_scheduled_purchases_schedule = var.fire_scheduled_purchases_schedule + # Server-task sweeps (process and notifications are opt-in) + enable_process_scheduled_purchases_schedule = var.enable_process_scheduled_purchases_schedule + process_scheduled_purchases_schedule = var.process_scheduled_purchases_schedule + enable_send_notifications_schedule = var.enable_send_notifications_schedule + send_notifications_schedule = var.send_notifications_schedule + enable_finalize_revocations_schedule = var.enable_finalize_revocations_schedule + finalize_revocations_schedule = var.finalize_revocations_schedule + enable_reap_stuck_purchases_schedule = var.enable_reap_stuck_purchases_schedule + reap_stuck_purchases_schedule = var.reap_stuck_purchases_schedule + enable_analytics_collect_schedule = var.enable_analytics_collect_schedule + analytics_collect_schedule = var.analytics_collect_schedule + # Additional environment variables archera_org_id = var.archera_org_id archera_plan_id = var.archera_plan_id diff --git a/terraform/environments/aws/variables.tf b/terraform/environments/aws/variables.tf index 4fccc28c2..14b8f7b7a 100644 --- a/terraform/environments/aws/variables.tf +++ b/terraform/environments/aws/variables.tf @@ -312,6 +312,66 @@ variable "fire_scheduled_purchases_schedule" { default = "rate(15 minutes)" } +variable "enable_process_scheduled_purchases_schedule" { + description = "Enable the scheduled process_scheduled_purchases sweep. Opt-in, default false." + type = bool + default = false +} + +variable "process_scheduled_purchases_schedule" { + description = "EventBridge schedule for the process_scheduled_purchases sweep (default rate(1 hour))." + type = string + default = "rate(1 hour)" +} + +variable "enable_send_notifications_schedule" { + description = "Enable the scheduled send_notifications sweep. Opt-in, default false; enable it together with process_scheduled_purchases." + type = bool + default = false +} + +variable "send_notifications_schedule" { + description = "EventBridge schedule for the send_notifications sweep (default rate(1 day))." + type = string + default = "rate(1 day)" +} + +variable "enable_finalize_revocations_schedule" { + description = "Enable the scheduled finalize_revocations sweep (idempotent audit repair)." + type = bool + default = true +} + +variable "finalize_revocations_schedule" { + description = "EventBridge schedule for the finalize_revocations sweep (default rate(1 hour))." + type = string + default = "rate(1 hour)" +} + +variable "enable_reap_stuck_purchases_schedule" { + description = "Enable the scheduled stuck-purchase reaper sweep (issue #678 backstop)." + type = bool + default = true +} + +variable "reap_stuck_purchases_schedule" { + description = "EventBridge schedule for the reap_stuck_purchases sweep (default rate(5 minutes))." + type = string + default = "rate(5 minutes)" +} + +variable "enable_analytics_collect_schedule" { + description = "Enable the scheduled savings-snapshot analytics collection." + type = bool + default = true +} + +variable "analytics_collect_schedule" { + description = "EventBridge schedule for the analytics_collect sweep (default rate(1 day))." + type = string + default = "rate(1 day)" +} + # ============================================== # Multi-Account Configuration # ============================================== diff --git a/terraform/modules/compute/scheduled_task_coverage_test.go b/terraform/modules/compute/scheduled_task_coverage_test.go index 52a098220..c9437dd41 100644 --- a/terraform/modules/compute/scheduled_task_coverage_test.go +++ b/terraform/modules/compute/scheduled_task_coverage_test.go @@ -40,10 +40,8 @@ const ( ) var ( - validDims = []string{dimTask, dimPass, dimDefault} - lambdaEnvGaps = []string{"analytics_collect", "finalize_revocations", "process_scheduled_purchases", - "reap_stuck_purchases", "send_notifications"} - allEight = []string{"analytics_collect", "cleanup", "collect_recommendations", "finalize_revocations", + validDims = []string{dimTask, dimPass, dimDefault} + allEight = []string{"analytics_collect", "cleanup", "collect_recommendations", "finalize_revocations", "fire_scheduled_purchases", "process_scheduled_purchases", "reap_stuck_purchases", "send_notifications"} sevenGCP = []string{"analytics_collect", "cleanup", "finalize_revocations", "fire_scheduled_purchases", "process_scheduled_purchases", "reap_stuck_purchases", "send_notifications"} @@ -54,11 +52,7 @@ var ( // aws/cleanup-lambda and gcp/cleanup-function run cleanup outside the server // task and are not scanned, so cleanup shows as a gap on AWS and GCP. var knownGaps = map[string]map[string][]string{ - "aws-lambda": { - dimTask: {"cleanup"}, - dimPass: lambdaEnvGaps, - dimDefault: lambdaEnvGaps, - }, + "aws-lambda": {dimTask: {"cleanup"}}, "aws-fargate": {dimTask: {"analytics_collect", "cleanup", "finalize_revocations", "process_scheduled_purchases", "reap_stuck_purchases", "send_notifications"}}, "cloudformation": {dimTask: {"analytics_collect", "cleanup", "finalize_revocations", From b82ca56e3bf106e59869e5b5888e2e2aaf6c732a Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 16 Oct 2026 12:26:07 +0200 Subject: [PATCH 3/4] build(terraform): record linux provider hashes in the lambda lock file The lambda module's lock file held only the darwin h1 hash for hashicorp/aws 5.100.0, so `init -lockfile=readonly` on the Linux CI runner rejected the downloaded provider. Recorded with `terraform providers lock` for linux_amd64, darwin_arm64 and darwin_amd64; the hashes match the fargate module's lock file. Co-Authored-By: Claude Opus 5.5 --- terraform/modules/compute/aws/lambda/.terraform.lock.hcl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/terraform/modules/compute/aws/lambda/.terraform.lock.hcl b/terraform/modules/compute/aws/lambda/.terraform.lock.hcl index cdc1668d4..8fbb17124 100644 --- a/terraform/modules/compute/aws/lambda/.terraform.lock.hcl +++ b/terraform/modules/compute/aws/lambda/.terraform.lock.hcl @@ -6,6 +6,8 @@ provider "registry.terraform.io/hashicorp/aws" { constraints = "~> 5.0" hashes = [ "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", + "h1:edXOJWE4ORX8Fm+dpVpICzMZJat4AX0VRCAy/xkcOc0=", + "h1:hd45qFU5cFuJMpFGdUniU9mVIr5LYVWP1uMeunBpYYs=", "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", From e9f43df191d7ae83e6971dfb08f05e641af86015 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 16 Oct 2026 12:34:17 +0200 Subject: [PATCH 4/4] docs(lambda): correct the stack_name limit and note #894 at the env layer The Lambda IAM role uses name_prefix "-lambda-", which the AWS provider caps at 38 chars, so stack_name tops out at 30, not 39. The env-layer process variable now carries the same #894 note as the module variable, and the tftest comment no longer calls its sample stack name the worst case. Co-Authored-By: Claude Opus 5.5 --- terraform/environments/aws/variables.tf | 2 +- terraform/modules/compute/aws/lambda/sweeps.tf | 4 ++-- terraform/modules/compute/aws/lambda/tests/sweeps.tftest.hcl | 2 +- terraform/modules/compute/aws/lambda/variables.tf | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/terraform/environments/aws/variables.tf b/terraform/environments/aws/variables.tf index 14b8f7b7a..c0acf8168 100644 --- a/terraform/environments/aws/variables.tf +++ b/terraform/environments/aws/variables.tf @@ -313,7 +313,7 @@ variable "fire_scheduled_purchases_schedule" { } variable "enable_process_scheduled_purchases_schedule" { - description = "Enable the scheduled process_scheduled_purchases sweep. Opt-in, default false." + description = "Enable the scheduled process_scheduled_purchases sweep. Opt-in, default false. Note (#894): the stuck-purchase reaper can fail stranded approved rows before process re-drives them." type = bool default = false } diff --git a/terraform/modules/compute/aws/lambda/sweeps.tf b/terraform/modules/compute/aws/lambda/sweeps.tf index 6f543fb1a..0efb13553 100644 --- a/terraform/modules/compute/aws/lambda/sweeps.tf +++ b/terraform/modules/compute/aws/lambda/sweeps.tf @@ -1,8 +1,8 @@ # Additional scheduled sweeps. Each entry becomes an EventBridge rule that # invokes the server task named by the map key. `rule` is the short rule-name # suffix: rule names are "-sweep-", so the longest suffix -# (at most 15 chars) fits the 64-char EventBridge limit; the module's stack_name -# maximum (39) is set by the longer "-fire-scheduled-purchases" rule in main.tf. +# (at most 15 chars) fits the 64-char EventBridge limit for any stack_name the +# module accepts (at most 30, set by the IAM role name_prefix in main.tf). # The "sweep-" infix keeps these names clear of the older per-task rules. # Retries stay at the EventBridge default: a re-run is safe under the per-task advisory lock and row CAS in the server. locals { diff --git a/terraform/modules/compute/aws/lambda/tests/sweeps.tftest.hcl b/terraform/modules/compute/aws/lambda/tests/sweeps.tftest.hcl index 3a8ca4af3..2b808e893 100644 --- a/terraform/modules/compute/aws/lambda/tests/sweeps.tftest.hcl +++ b/terraform/modules/compute/aws/lambda/tests/sweeps.tftest.hcl @@ -4,7 +4,7 @@ mock_provider "aws" {} variables { - # Worst case for the 64-char EventBridge rule-name limit. + # A long production-style name; rule names are asserted against the 64-char limit. stack_name = "cudly-production-abcd1234" permissions_boundary_arn = "arn:aws:iam::123456789012:policy/cudly-boundary" environment = "production" diff --git a/terraform/modules/compute/aws/lambda/variables.tf b/terraform/modules/compute/aws/lambda/variables.tf index 782a1b5a5..4d858da30 100644 --- a/terraform/modules/compute/aws/lambda/variables.tf +++ b/terraform/modules/compute/aws/lambda/variables.tf @@ -1,5 +1,5 @@ variable "stack_name" { - description = "Name of the stack. At most 39 chars: the longest rule suffix is \"-fire-scheduled-purchases\" (25 chars) and EventBridge caps rule names at 64. The sweep rules (\"-sweep-\", up to 15 chars) fit well inside that." + description = "Name of the stack. At most 30 chars: the Lambda IAM role uses name_prefix \"-lambda-\", which the AWS provider caps at 38 chars. Rule names (longest \"-fire-scheduled-purchases\", sweeps \"-sweep-\" up to 15 chars) fit well inside EventBridge's 64." type = string }