From 890d59db6dae61bade1482666d40360a0e809a06 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Wed, 28 Oct 2026 21:14:36 +0000 Subject: [PATCH 1/4] fix(iac): fire delayed purchases by default; collect AWS recommendations at 02:00 UTC Owner decision D33 (#712): the fire_scheduled_purchases sweep is now on by default on every platform. enable_fire_scheduled_purchases_schedule defaults to true in the aws, azure and gcp environment roots and in the Lambda, Fargate, Cloud Run, GKE, Container Apps and AKS modules, and the CloudFormation EnableFireScheduledPurchasesSchedule parameter defaults to "true". The sweep only executes purchases already approved for a later time, and the 24h due-row age cap (#1549) refuses and alerts on anything older, so the first run cannot buy a stale backlog. The process sweep and every other opt-in toggle stay off. The Go policy test now treats fire as default-on (it would flag a committed AWS tfvars that turns it off), and the module terraform tests expect the fire job by default and cover the opt-out. Owner decision D32 (#756): the AWS recommendation_schedule default moves from rate(1 day) to cron(0 2 * * ? *), 02:00 UTC like GCP and Azure, in the env root, the Lambda and Fargate modules, the github-*.tfvars and the example tfvars. A rate() schedule has no known phase, so the freshness endpoint's next_collection_at was always null on AWS. A new test reads every default and committed value and checks the API's NextCollectionAt returns the next 02:00 UTC for each. Refs #712 Refs #756 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 17 +++++ cloudformation/stacks/CUDly/template.yaml | 6 +- internal/purchase/due_age_cap.go | 11 +-- specs/recommendations-cache.md | 3 +- terraform/environments/aws/dev.tfvars.example | 2 +- terraform/environments/aws/github-dev.tfvars | 2 +- terraform/environments/aws/github-prod.tfvars | 2 +- .../environments/aws/github-staging.tfvars | 2 +- terraform/environments/aws/variables.tf | 8 +-- terraform/environments/azure/variables.tf | 4 +- terraform/environments/gcp/variables.tf | 4 +- .../aws/fargate/tests/sweeps.tftest.hcl | 23 ++++-- .../modules/compute/aws/fargate/variables.tf | 8 +-- terraform/modules/compute/aws/lambda/main.tf | 5 +- .../aws/lambda/tests/sweeps.tftest.hcl | 29 +++++++- .../modules/compute/aws/lambda/variables.tf | 8 +-- .../compute/azure/aks/scheduled-tasks.tf | 2 +- .../aks/tests/scheduled_tasks.tftest.hcl | 17 ++--- .../modules/compute/azure/aks/variables.tf | 4 +- .../azure/container-apps/scheduled-sweeps.tf | 2 +- .../tests/scheduled_tasks.tftest.hcl | 22 ++++-- .../compute/azure/container-apps/variables.tf | 4 +- .../compute/gcp/cloud-run/scheduled-tasks.tf | 7 +- .../tests/scheduled_tasks.tftest.hcl | 27 +++++-- .../compute/gcp/cloud-run/variables.tf | 4 +- .../compute/gcp/gke/scheduled-tasks.tf | 7 +- .../modules/compute/gcp/gke/variables.tf | 4 +- .../recommendation_schedule_env_test.go | 70 +++++++++++++++++++ .../compute/scheduled_task_cfn_test.go | 5 +- .../compute/scheduled_task_coverage_test.go | 6 +- .../compute/scheduled_task_env_test.go | 18 +++-- terraform/profiles/aws/dev.tfvars.example | 2 +- .../profiles/aws/fargate-dev.tfvars.example | 2 +- terraform/profiles/aws/prod.tfvars.example | 2 +- 34 files changed, 259 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 592c1cc9f..fcad05353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,23 @@ are the full record until the next release. new-style `es:*ReservedInstance*` OpenSearch actions (replacing the legacy `es:*ReservedElasticsearch*` names). +### Changed + +- **The fire_scheduled_purchases sweep is now on by default on every + platform** (Lambda, Fargate, CloudFormation, Cloud Run, GKE, Container Apps + and AKS), every 15 minutes. It executes delayed auto-approved purchases once + their scheduled time passes; until now they never ran unless the operator + turned the sweep on. The server refuses (and sends the purchase-failed + alert for) any purchase due more than 24h ago, so the first run cannot buy + a stale backlog. Set `enable_fire_scheduled_purchases_schedule = false` + (CloudFormation: `EnableFireScheduledPurchasesSchedule=false`) to opt out. + The process_scheduled_purchases sweep stays opt-in (#712) +- **AWS recommendation collection moves to 02:00 UTC daily** + (`recommendation_schedule = "cron(0 2 * * ? *)"`, was `rate(1 day)`), the + same time as GCP and Azure. With a fixed time the recommendations freshness + endpoint can report `next_collection_at` on AWS too. Deployments that set + their own `recommendation_schedule` are unaffected (#756) + ### Fixed - Account scopes match `allowed_accounts` entries by account ID only. A diff --git a/cloudformation/stacks/CUDly/template.yaml b/cloudformation/stacks/CUDly/template.yaml index c60f2a3f2..64f1ba5f7 100644 --- a/cloudformation/stacks/CUDly/template.yaml +++ b/cloudformation/stacks/CUDly/template.yaml @@ -207,14 +207,16 @@ Parameters: EnableFireScheduledPurchasesSchedule: Type: String - Default: "false" + Default: "true" AllowedValues: - "true" - "false" Description: >- Run the fire_scheduled_purchases task on a schedule. It executes purchases in status scheduled whose scheduled time has passed (the - delayed auto-approve path), so it makes real purchases. Opt-in. + delayed auto-approve path), so it makes real purchases. On by default: + the server refuses, and alerts on, any purchase due more than 24h ago, + so turning it on cannot buy a stale backlog. Set "false" to opt out. FireScheduledPurchasesSchedule: Type: String diff --git a/internal/purchase/due_age_cap.go b/internal/purchase/due_age_cap.go index 284546c5f..68096aab9 100644 --- a/internal/purchase/due_age_cap.go +++ b/internal/purchase/due_age_cap.go @@ -15,11 +15,12 @@ import ( const DefaultMaxDueRowAge = 24 * time.Hour // ErrDueRowTooOld marks a row the fire or process sweep refused because it was -// due more than the due-row age cap ago (issue #712). The sweeps are opt-in on -// most platforms, so the first tick after one is enabled can find a long -// backlog of overdue rows whose recommendations and prices are stale; buying -// them all at once is the outcome this cap prevents. The row ends failed with -// no_submission set, so it can be retried deliberately from History. +// due more than the due-row age cap ago (issue #712). The first tick after a +// sweep is enabled (the fire sweep by default since #712, the process sweep +// on opt-in) can find a long backlog of overdue rows whose recommendations +// and prices are stale; buying them all at once is the outcome this cap +// prevents. The row ends failed with no_submission set, so it can be retried +// deliberately from History. var ErrDueRowTooOld = errors.New("scheduled purchase skipped: it was due too long ago") // dueRowMaxAge is the sweeps' age cap, DefaultMaxDueRowAge when unset. diff --git a/specs/recommendations-cache.md b/specs/recommendations-cache.md index 7386ac435..df9013f60 100644 --- a/specs/recommendations-cache.md +++ b/specs/recommendations-cache.md @@ -54,7 +54,8 @@ entry point. Flow: - **Scheduled cron** (AWS EventBridge / GCP Cloud Scheduler / Azure Logic App) — runs on all runtimes at `var.recommendation_schedule`, - default `rate(1 day)`. + default daily at 02:00 UTC (`cron(0 2 * * ? *)` on AWS, `0 2 * * *` on + GCP and Azure). - **Manual `POST /api/recommendations/refresh`** — user-driven via the Refresh button on the dashboard. All runtimes. - **In-request stale-while-revalidate** — GCP Cloud Run and Azure diff --git a/terraform/environments/aws/dev.tfvars.example b/terraform/environments/aws/dev.tfvars.example index 225468c6a..c2b021e75 100644 --- a/terraform/environments/aws/dev.tfvars.example +++ b/terraform/environments/aws/dev.tfvars.example @@ -112,7 +112,7 @@ frontend_price_class = "PriceClass_100" # US, Canada, Europe # ============================================== enable_scheduled_tasks = true -recommendation_schedule = "rate(24 hours)" +recommendation_schedule = "cron(0 2 * * ? *)" # daily at 02:00 UTC # ============================================== # Docker Build diff --git a/terraform/environments/aws/github-dev.tfvars b/terraform/environments/aws/github-dev.tfvars index 40ca20970..3fb9098cc 100644 --- a/terraform/environments/aws/github-dev.tfvars +++ b/terraform/environments/aws/github-dev.tfvars @@ -81,7 +81,7 @@ create_subdomain_zone = false # ============================================== enable_scheduled_tasks = true -recommendation_schedule = "rate(1 day)" +recommendation_schedule = "cron(0 2 * * ? *)" # daily at 02:00 UTC # ============================================== # Multi-Account Cross-Account Access diff --git a/terraform/environments/aws/github-prod.tfvars b/terraform/environments/aws/github-prod.tfvars index 6f15a1cde..84f66882a 100644 --- a/terraform/environments/aws/github-prod.tfvars +++ b/terraform/environments/aws/github-prod.tfvars @@ -88,7 +88,7 @@ create_subdomain_zone = false # ============================================== enable_scheduled_tasks = true -recommendation_schedule = "rate(1 day)" +recommendation_schedule = "cron(0 2 * * ? *)" # daily at 02:00 UTC # ============================================== # Multi-Account Cross-Account Access diff --git a/terraform/environments/aws/github-staging.tfvars b/terraform/environments/aws/github-staging.tfvars index ee816cd41..4ee59ab4b 100644 --- a/terraform/environments/aws/github-staging.tfvars +++ b/terraform/environments/aws/github-staging.tfvars @@ -78,7 +78,7 @@ create_subdomain_zone = false # ============================================== enable_scheduled_tasks = true -recommendation_schedule = "rate(1 day)" +recommendation_schedule = "cron(0 2 * * ? *)" # daily at 02:00 UTC # ============================================== # Multi-Account Cross-Account Access diff --git a/terraform/environments/aws/variables.tf b/terraform/environments/aws/variables.tf index 015e60436..9bddcef00 100644 --- a/terraform/environments/aws/variables.tf +++ b/terraform/environments/aws/variables.tf @@ -365,9 +365,9 @@ variable "enable_scheduled_tasks" { } variable "recommendation_schedule" { - description = "Schedule expression for recommendation collection" + description = "EventBridge schedule expression for recommendation collection. Default: daily at 02:00 UTC, the same time as GCP and Azure. A cron() schedule lets the API report next_collection_at; a rate() schedule leaves it null because its phase is unknown (#756)." type = string - default = "rate(1 day)" + default = "cron(0 2 * * ? *)" } variable "enable_ri_exchange_schedule" { @@ -395,9 +395,9 @@ variable "ladder_run_schedule" { } variable "enable_fire_scheduled_purchases_schedule" { - description = "Enable the scheduled fire_scheduled_purchases sweep. Default false until auto-approve (L9) is promoted to GA." + description = "Schedule the fire_scheduled_purchases sweep, which executes delayed auto-approved purchases once their scheduled time has passed. On by default: the server refuses, and alerts on, any purchase due more than 24h ago (SCHEDULED_PURCHASE_MAX_OVERDUE, #1549), so turning it on cannot buy a stale backlog. Set false to opt out." type = bool - default = false + default = true } variable "fire_scheduled_purchases_schedule" { diff --git a/terraform/environments/azure/variables.tf b/terraform/environments/azure/variables.tf index 9f0d4ceae..477f9f3d1 100644 --- a/terraform/environments/azure/variables.tf +++ b/terraform/environments/azure/variables.tf @@ -444,9 +444,9 @@ variable "analytics_collect_schedule" { } variable "enable_fire_scheduled_purchases_schedule" { - description = "Schedule the fire_scheduled_purchases sweep, which fires delayed auto-approved purchases. Opt-in: default false." + description = "Schedule the fire_scheduled_purchases sweep, which executes delayed auto-approved purchases once their scheduled time has passed. On by default: the server refuses, and alerts on, any purchase due more than 24h ago (SCHEDULED_PURCHASE_MAX_OVERDUE, #1549), so turning it on cannot buy a stale backlog. Set false to opt out." type = bool - default = false + default = true } variable "fire_scheduled_purchases_schedule" { diff --git a/terraform/environments/gcp/variables.tf b/terraform/environments/gcp/variables.tf index e132ee443..06034bbbe 100644 --- a/terraform/environments/gcp/variables.tf +++ b/terraform/environments/gcp/variables.tf @@ -370,9 +370,9 @@ variable "analytics_collect_schedule" { } variable "enable_fire_scheduled_purchases_schedule" { - description = "Schedule the fire_scheduled_purchases sweep, which fires delayed auto-approved purchases. Opt-in: default false." + description = "Schedule the fire_scheduled_purchases sweep, which executes delayed auto-approved purchases once their scheduled time has passed. On by default: the server refuses, and alerts on, any purchase due more than 24h ago (SCHEDULED_PURCHASE_MAX_OVERDUE, #1549), so turning it on cannot buy a stale backlog. Set false to opt out." type = bool - default = false + default = true } variable "fire_scheduled_purchases_schedule" { diff --git a/terraform/modules/compute/aws/fargate/tests/sweeps.tftest.hcl b/terraform/modules/compute/aws/fargate/tests/sweeps.tftest.hcl index 3d9564308..62a8873ea 100644 --- a/terraform/modules/compute/aws/fargate/tests/sweeps.tftest.hcl +++ b/terraform/modules/compute/aws/fargate/tests/sweeps.tftest.hcl @@ -36,8 +36,16 @@ run "defaults" { error_message = "Each active sweep needs a target, and the sweeps need their invoker role and policy." } 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." + condition = length(aws_cloudwatch_event_rule.fire_scheduled_purchases) == 1 && length(aws_cloudwatch_event_target.fire_scheduled_purchases) == 1 + error_message = "The fire sweep is on by default (#712), with its target." + } + assert { + condition = aws_cloudwatch_event_rule.fire_scheduled_purchases[0].schedule_expression == "rate(15 minutes)" + error_message = "The fire sweep runs every 15 minutes by default, as on Lambda." + } + assert { + condition = length(aws_cloudwatch_event_rule.ladder_run) == 0 && length(aws_cloudwatch_event_rule.ri_exchange) == 0 + error_message = "ladder_run and ri_exchange stay opt-in." } assert { condition = alltrue([ @@ -100,13 +108,18 @@ run "all_off" { command = plan variables { - enable_reap_stuck_purchases_schedule = false - enable_analytics_collect_schedule = false - enable_finalize_revocations_schedule = false + enable_reap_stuck_purchases_schedule = false + enable_analytics_collect_schedule = false + enable_finalize_revocations_schedule = false + enable_fire_scheduled_purchases_schedule = false } assert { condition = length(aws_cloudwatch_event_rule.sweep) == 0 && length(aws_iam_role.eventbridge_sweeps) == 0 && length(aws_iam_role_policy.eventbridge_sweeps) == 0 error_message = "With every sweep off, no rule and no invoker role may be created." } + assert { + condition = length(aws_cloudwatch_event_rule.fire_scheduled_purchases) == 0 && length(aws_cloudwatch_event_target.fire_scheduled_purchases) == 0 + error_message = "Setting enable_fire_scheduled_purchases_schedule = false must remove the fire rule and target." + } } diff --git a/terraform/modules/compute/aws/fargate/variables.tf b/terraform/modules/compute/aws/fargate/variables.tf index 3d89dc4d5..55744b93e 100644 --- a/terraform/modules/compute/aws/fargate/variables.tf +++ b/terraform/modules/compute/aws/fargate/variables.tf @@ -196,9 +196,9 @@ variable "enable_scheduled_tasks" { } variable "recommendation_schedule" { - description = "Schedule expression for recommendation collection" + description = "EventBridge schedule expression for recommendation collection. Default: daily at 02:00 UTC, the same time as GCP and Azure. A cron() schedule lets the API report next_collection_at; a rate() schedule leaves it null because its phase is unknown (#756)." type = string - default = "rate(1 day)" + default = "cron(0 2 * * ? *)" } variable "enable_ri_exchange_schedule" { @@ -226,9 +226,9 @@ variable "ladder_run_schedule" { } variable "enable_fire_scheduled_purchases_schedule" { - description = "Enable the scheduled fire_scheduled_purchases sweep. Default false until auto-approve (L9) is promoted to GA." + description = "Schedule the fire_scheduled_purchases sweep, which executes delayed auto-approved purchases once their scheduled time has passed. On by default: the server refuses, and alerts on, any purchase due more than 24h ago (SCHEDULED_PURCHASE_MAX_OVERDUE, #1549), so turning it on cannot buy a stale backlog. Set false to opt out." type = bool - default = false + default = true } variable "fire_scheduled_purchases_schedule" { diff --git a/terraform/modules/compute/aws/lambda/main.tf b/terraform/modules/compute/aws/lambda/main.tf index 3ab5f07a4..91968553c 100644 --- a/terraform/modules/compute/aws/lambda/main.tf +++ b/terraform/modules/compute/aws/lambda/main.tf @@ -777,8 +777,9 @@ resource "aws_lambda_permission" "eventbridge_ladder_run" { # 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. +# never fire between daily ladder runs. On by default (owner decision +# for #712): the server refuses any row due more than 24h ago (#1549), +# so a first tick after enabling cannot buy a stale backlog. resource "aws_cloudwatch_event_rule" "fire_scheduled_purchases" { count = var.enable_fire_scheduled_purchases_schedule ? 1 : 0 diff --git a/terraform/modules/compute/aws/lambda/tests/sweeps.tftest.hcl b/terraform/modules/compute/aws/lambda/tests/sweeps.tftest.hcl index 716efc18e..c6724f577 100644 --- a/terraform/modules/compute/aws/lambda/tests/sweeps.tftest.hcl +++ b/terraform/modules/compute/aws/lambda/tests/sweeps.tftest.hcl @@ -34,8 +34,20 @@ run "defaults" { 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." + condition = length(aws_cloudwatch_event_rule.fire_scheduled_purchases) == 1 && length(aws_cloudwatch_event_target.fire_scheduled_purchases) == 1 && length(aws_lambda_permission.eventbridge_fire_scheduled_purchases) == 1 + error_message = "The fire sweep is on by default (#712), with its target and permission." + } + assert { + condition = aws_cloudwatch_event_rule.fire_scheduled_purchases[0].schedule_expression == "rate(15 minutes)" + error_message = "The fire sweep runs every 15 minutes by default." + } + assert { + condition = length(aws_cloudwatch_event_rule.ladder_run) == 0 && length(aws_cloudwatch_event_rule.ri_exchange) == 0 + error_message = "ladder_run and ri_exchange stay opt-in." + } + assert { + condition = aws_cloudwatch_event_rule.scheduled_recommendations[0].schedule_expression == "cron(0 2 * * ? *)" + error_message = "Recommendations are collected daily at 02:00 UTC by default, on a cron the API can report next_collection_at for (#756)." } assert { condition = alltrue([ @@ -109,3 +121,16 @@ run "opt_in" { error_message = "Each target input must name its task." } } + +run "fire_opt_out" { + command = plan + + variables { + enable_fire_scheduled_purchases_schedule = false + } + + assert { + condition = length(aws_cloudwatch_event_rule.fire_scheduled_purchases) == 0 && length(aws_cloudwatch_event_target.fire_scheduled_purchases) == 0 && length(aws_lambda_permission.eventbridge_fire_scheduled_purchases) == 0 + error_message = "Setting enable_fire_scheduled_purchases_schedule = false must remove the fire rule, target and permission." + } +} diff --git a/terraform/modules/compute/aws/lambda/variables.tf b/terraform/modules/compute/aws/lambda/variables.tf index 79c87e20a..8865b994a 100644 --- a/terraform/modules/compute/aws/lambda/variables.tf +++ b/terraform/modules/compute/aws/lambda/variables.tf @@ -190,9 +190,9 @@ variable "enable_scheduled_tasks" { } variable "recommendation_schedule" { - description = "EventBridge schedule expression for recommendations" + description = "EventBridge schedule expression for recommendation collection. Default: daily at 02:00 UTC, the same time as GCP and Azure. A cron() schedule lets the API report next_collection_at; a rate() schedule leaves it null because its phase is unknown (#756)." type = string - default = "rate(1 day)" + default = "cron(0 2 * * ? *)" } variable "enable_ri_exchange_schedule" { @@ -244,9 +244,9 @@ variable "ladder_run_schedule" { } 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." + description = "Schedule the fire_scheduled_purchases sweep, which executes delayed auto-approved purchases once their scheduled time has passed. On by default: the server refuses, and alerts on, any purchase due more than 24h ago (SCHEDULED_PURCHASE_MAX_OVERDUE, #1549), so turning it on cannot buy a stale backlog. Set false to opt out." type = bool - default = false + default = true } variable "fire_scheduled_purchases_schedule" { diff --git a/terraform/modules/compute/azure/aks/scheduled-tasks.tf b/terraform/modules/compute/azure/aks/scheduled-tasks.tf index 01cb54803..27bad22bc 100644 --- a/terraform/modules/compute/azure/aks/scheduled-tasks.tf +++ b/terraform/modules/compute/azure/aks/scheduled-tasks.tf @@ -15,7 +15,7 @@ locals { # Keyed by server task name. `job` is the CronJob name suffix. Default-on - # tasks match the other platforms; fire_scheduled_purchases, + # tasks match the other platforms, including fire_scheduled_purchases; # process_scheduled_purchases, send_notifications and cleanup are opt-in # (default off, as decided for #712). ri_exchange_reshape (as on Container # Apps, Cloud Run and Fargate) and ladder_run (as on Fargate and Lambda) are diff --git a/terraform/modules/compute/azure/aks/tests/scheduled_tasks.tftest.hcl b/terraform/modules/compute/azure/aks/tests/scheduled_tasks.tftest.hcl index 0aaf890ed..2eb123f3b 100644 --- a/terraform/modules/compute/azure/aks/tests/scheduled_tasks.tftest.hcl +++ b/terraform/modules/compute/azure/aks/tests/scheduled_tasks.tftest.hcl @@ -54,8 +54,8 @@ run "defaults" { } assert { - condition = toset(keys(kubernetes_cron_job_v1.sweep)) == toset(["analytics_collect", "collect_recommendations", "finalize_revocations", "reap_stuck_purchases"]) - error_message = "Default-on tasks are collect_recommendations, reap_stuck_purchases, analytics_collect and finalize_revocations; fire, process, send_notifications and cleanup are opt-in." + condition = toset(keys(kubernetes_cron_job_v1.sweep)) == toset(["analytics_collect", "collect_recommendations", "finalize_revocations", "fire_scheduled_purchases", "reap_stuck_purchases"]) + error_message = "Default-on tasks are collect_recommendations, reap_stuck_purchases, analytics_collect, finalize_revocations and fire_scheduled_purchases; process, send_notifications and cleanup are opt-in." } assert { condition = alltrue([ @@ -67,12 +67,12 @@ run "defaults" { assert { condition = alltrue([ for k, j in kubernetes_cron_job_v1.sweep : - j.spec[0].schedule == { collect_recommendations = var.recommendation_schedule, reap_stuck_purchases = var.reap_stuck_purchases_schedule, analytics_collect = var.analytics_collect_schedule, finalize_revocations = var.finalize_revocations_schedule }[k] + j.spec[0].schedule == { collect_recommendations = var.recommendation_schedule, reap_stuck_purchases = var.reap_stuck_purchases_schedule, analytics_collect = var.analytics_collect_schedule, finalize_revocations = var.finalize_revocations_schedule, fire_scheduled_purchases = var.fire_scheduled_purchases_schedule }[k] ]) error_message = "Each schedule must come from its variable." } assert { - condition = toset([for j in kubernetes_cron_job_v1.sweep : j.metadata[0].name]) == toset(["cudly-task-collect", "cudly-task-reap", "cudly-task-analytics", "cudly-task-finalize"]) + condition = toset([for j in kubernetes_cron_job_v1.sweep : j.metadata[0].name]) == toset(["cudly-task-collect", "cudly-task-reap", "cudly-task-analytics", "cudly-task-finalize", "cudly-task-fire"]) error_message = "CronJob names must be distinct -task- names." } assert { @@ -191,10 +191,11 @@ run "all_off" { command = plan variables { - enable_scheduled_tasks = false - enable_reap_stuck_purchases_schedule = false - enable_analytics_collect_schedule = false - enable_finalize_revocations_schedule = false + enable_scheduled_tasks = false + enable_reap_stuck_purchases_schedule = false + enable_analytics_collect_schedule = false + enable_finalize_revocations_schedule = false + enable_fire_scheduled_purchases_schedule = false } plan_options { diff --git a/terraform/modules/compute/azure/aks/variables.tf b/terraform/modules/compute/azure/aks/variables.tf index 0f235a6a7..c29cafd29 100644 --- a/terraform/modules/compute/azure/aks/variables.tf +++ b/terraform/modules/compute/azure/aks/variables.tf @@ -277,9 +277,9 @@ variable "analytics_collect_schedule" { } variable "enable_fire_scheduled_purchases_schedule" { - description = "Schedule the fire_scheduled_purchases sweep, which fires delayed auto-approved purchases. Opt-in: default false." + description = "Schedule the fire_scheduled_purchases sweep, which executes delayed auto-approved purchases once their scheduled time has passed. On by default: the server refuses, and alerts on, any purchase due more than 24h ago (SCHEDULED_PURCHASE_MAX_OVERDUE, #1549), so turning it on cannot buy a stale backlog. Set false to opt out." type = bool - default = false + default = true } variable "fire_scheduled_purchases_schedule" { diff --git a/terraform/modules/compute/azure/container-apps/scheduled-sweeps.tf b/terraform/modules/compute/azure/container-apps/scheduled-sweeps.tf index 2ee65fe8e..7e8eb1316 100644 --- a/terraform/modules/compute/azure/container-apps/scheduled-sweeps.tf +++ b/terraform/modules/compute/azure/container-apps/scheduled-sweeps.tf @@ -14,7 +14,7 @@ locals { # Keyed by server task name. `job` is the workflow name suffix. Default-on - # tasks match the other platforms; fire_scheduled_purchases, + # tasks match the other platforms, including fire_scheduled_purchases; # process_scheduled_purchases and send_notifications are opt-in (default # off, as decided for #712). cleanup has its own workflow in # scheduled-tasks.tf. diff --git a/terraform/modules/compute/azure/container-apps/tests/scheduled_tasks.tftest.hcl b/terraform/modules/compute/azure/container-apps/tests/scheduled_tasks.tftest.hcl index ad9e32698..4e39ed95c 100644 --- a/terraform/modules/compute/azure/container-apps/tests/scheduled_tasks.tftest.hcl +++ b/terraform/modules/compute/azure/container-apps/tests/scheduled_tasks.tftest.hcl @@ -1,8 +1,9 @@ # Scheduled server sweeps on Container Apps (#712). Every provider is mocked; # nothing here reaches Azure. Each sweep must be a Logic App workflow that # reads the scheduled-task secret with its own identity and POSTs to -# /api/scheduled/, on a recurrence equal to its cron schedule, and the -# money sweeps must stay off unless asked for. +# /api/scheduled/, on a recurrence equal to its cron schedule. The fire +# sweep is on by default (#712); the process and notification sweeps stay off +# unless asked for. mock_provider "azurerm" { # Resources referenced by ID elsewhere in the module are parsed, so the mock # needs well-formed ARM IDs. @@ -54,8 +55,8 @@ run "defaults_schedule_only_the_default_on_sweeps" { command = apply assert { - condition = toset(keys(azurerm_logic_app_workflow.sweep)) == toset(["analytics_collect", "finalize_revocations", "reap_stuck_purchases"]) - error_message = "By default only reap, analytics and finalize run; fire, process and send_notifications are opt-in." + condition = toset(keys(azurerm_logic_app_workflow.sweep)) == toset(["analytics_collect", "finalize_revocations", "fire_scheduled_purchases", "reap_stuck_purchases"]) + error_message = "By default reap, analytics, finalize and fire run; process and send_notifications are opt-in." } assert { @@ -146,6 +147,19 @@ run "opt_in_sweeps_follow_their_flags" { } } +run "fire_opt_out" { + command = plan + + variables { + enable_fire_scheduled_purchases_schedule = false + } + + assert { + condition = toset(keys(azurerm_logic_app_workflow.sweep)) == toset(["analytics_collect", "finalize_revocations", "reap_stuck_purchases"]) + error_message = "Setting enable_fire_scheduled_purchases_schedule = false must remove the fire workflow." + } +} + run "scheduler_off_creates_no_sweeps" { command = plan diff --git a/terraform/modules/compute/azure/container-apps/variables.tf b/terraform/modules/compute/azure/container-apps/variables.tf index 086fa6527..c16bc6dc7 100644 --- a/terraform/modules/compute/azure/container-apps/variables.tf +++ b/terraform/modules/compute/azure/container-apps/variables.tf @@ -285,9 +285,9 @@ variable "analytics_collect_schedule" { } variable "enable_fire_scheduled_purchases_schedule" { - description = "Schedule the fire_scheduled_purchases sweep, which fires delayed auto-approved purchases. Opt-in: default false." + description = "Schedule the fire_scheduled_purchases sweep, which executes delayed auto-approved purchases once their scheduled time has passed. On by default: the server refuses, and alerts on, any purchase due more than 24h ago (SCHEDULED_PURCHASE_MAX_OVERDUE, #1549), so turning it on cannot buy a stale backlog. Set false to opt out." type = bool - default = false + default = true } variable "fire_scheduled_purchases_schedule" { diff --git a/terraform/modules/compute/gcp/cloud-run/scheduled-tasks.tf b/terraform/modules/compute/gcp/cloud-run/scheduled-tasks.tf index 20c3c8b8a..a42d5881c 100644 --- a/terraform/modules/compute/gcp/cloud-run/scheduled-tasks.tf +++ b/terraform/modules/compute/gcp/cloud-run/scheduled-tasks.tf @@ -14,9 +14,10 @@ locals { # Keyed by server task name. `job` is the Cloud Scheduler job name suffix. - # Default-on tasks match the other platforms; fire_scheduled_purchases, - # process_scheduled_purchases, send_notifications and cleanup are opt-in - # (default off, as decided for #712). + # Default-on tasks match the other platforms, including + # fire_scheduled_purchases; process_scheduled_purchases, + # send_notifications and cleanup are opt-in (default off, as decided for + # #712). sweep_tasks = { reap_stuck_purchases = { job = "reap", enabled = var.enable_reap_stuck_purchases_schedule, schedule = var.reap_stuck_purchases_schedule } analytics_collect = { job = "analytics", enabled = var.enable_analytics_collect_schedule, schedule = var.analytics_collect_schedule } diff --git a/terraform/modules/compute/gcp/cloud-run/tests/scheduled_tasks.tftest.hcl b/terraform/modules/compute/gcp/cloud-run/tests/scheduled_tasks.tftest.hcl index f41e1eb16..e995ef004 100644 --- a/terraform/modules/compute/gcp/cloud-run/tests/scheduled_tasks.tftest.hcl +++ b/terraform/modules/compute/gcp/cloud-run/tests/scheduled_tasks.tftest.hcl @@ -1,8 +1,9 @@ # Scheduled server sweeps on Cloud Run (#712). Every provider is mocked; # nothing here reaches GCP. Each sweep must be a Cloud Scheduler job calling # /api/scheduled/ with an OIDC token whose audience the service's -# SCHEDULED_TASK_OIDC_AUDIENCE allowlist accepts, and the money and retention -# sweeps must stay off unless asked for. +# SCHEDULED_TASK_OIDC_AUDIENCE allowlist accepts. The fire sweep is on by +# default (#712); the process, notification and retention sweeps stay off +# unless asked for. mock_provider "google" {} variables { @@ -22,8 +23,8 @@ run "defaults_schedule_only_the_default_on_sweeps" { command = apply assert { - condition = toset(keys(google_cloud_scheduler_job.sweep)) == toset(["analytics_collect", "finalize_revocations", "reap_stuck_purchases"]) - error_message = "By default only reap, analytics and finalize run; fire, process, send_notifications and cleanup are opt-in." + condition = toset(keys(google_cloud_scheduler_job.sweep)) == toset(["analytics_collect", "finalize_revocations", "fire_scheduled_purchases", "reap_stuck_purchases"]) + error_message = "By default reap, analytics, finalize and fire run; process, send_notifications and cleanup are opt-in." } assert { @@ -97,6 +98,24 @@ run "opt_in_sweeps_follow_their_flags" { } } +run "fire_opt_out" { + command = apply + + variables { + enable_fire_scheduled_purchases_schedule = false + } + + assert { + condition = toset(keys(google_cloud_scheduler_job.sweep)) == toset(["analytics_collect", "finalize_revocations", "reap_stuck_purchases"]) + error_message = "Setting enable_fire_scheduled_purchases_schedule = false must remove the fire job." + } + + assert { + condition = !contains(split(",", one([for e in google_cloud_run_v2_service.main.template[0].containers[0].env : e.value if e.name == "SCHEDULED_TASK_OIDC_AUDIENCE"])), "cudly-test/api/scheduled/fire_scheduled_purchases") + error_message = "An opted-out fire sweep's audience must not be accepted." + } +} + run "scheduler_off_creates_no_sweeps" { command = apply diff --git a/terraform/modules/compute/gcp/cloud-run/variables.tf b/terraform/modules/compute/gcp/cloud-run/variables.tf index fa4391157..defe61990 100644 --- a/terraform/modules/compute/gcp/cloud-run/variables.tf +++ b/terraform/modules/compute/gcp/cloud-run/variables.tf @@ -216,9 +216,9 @@ variable "analytics_collect_schedule" { } variable "enable_fire_scheduled_purchases_schedule" { - description = "Schedule the fire_scheduled_purchases sweep, which fires delayed auto-approved purchases. Opt-in: default false." + description = "Schedule the fire_scheduled_purchases sweep, which executes delayed auto-approved purchases once their scheduled time has passed. On by default: the server refuses, and alerts on, any purchase due more than 24h ago (SCHEDULED_PURCHASE_MAX_OVERDUE, #1549), so turning it on cannot buy a stale backlog. Set false to opt out." type = bool - default = false + default = true } variable "fire_scheduled_purchases_schedule" { diff --git a/terraform/modules/compute/gcp/gke/scheduled-tasks.tf b/terraform/modules/compute/gcp/gke/scheduled-tasks.tf index f3fab5660..0f11bd65b 100644 --- a/terraform/modules/compute/gcp/gke/scheduled-tasks.tf +++ b/terraform/modules/compute/gcp/gke/scheduled-tasks.tf @@ -14,9 +14,10 @@ locals { # Keyed by server task name. `job` is the Cloud Scheduler job name suffix. - # Default-on tasks match the other platforms; fire_scheduled_purchases, - # process_scheduled_purchases, send_notifications and cleanup are opt-in - # (default off, as decided for #712). + # Default-on tasks match the other platforms, including + # fire_scheduled_purchases; process_scheduled_purchases, + # send_notifications and cleanup are opt-in (default off, as decided for + # #712). sweep_tasks = { reap_stuck_purchases = { job = "reap", enabled = var.enable_reap_stuck_purchases_schedule, schedule = var.reap_stuck_purchases_schedule } analytics_collect = { job = "analytics", enabled = var.enable_analytics_collect_schedule, schedule = var.analytics_collect_schedule } diff --git a/terraform/modules/compute/gcp/gke/variables.tf b/terraform/modules/compute/gcp/gke/variables.tf index 836948d3f..0d1cea345 100644 --- a/terraform/modules/compute/gcp/gke/variables.tf +++ b/terraform/modules/compute/gcp/gke/variables.tf @@ -306,9 +306,9 @@ variable "analytics_collect_schedule" { } variable "enable_fire_scheduled_purchases_schedule" { - description = "Schedule the fire_scheduled_purchases sweep, which fires delayed auto-approved purchases. Opt-in: default false." + description = "Schedule the fire_scheduled_purchases sweep, which executes delayed auto-approved purchases once their scheduled time has passed. On by default: the server refuses, and alerts on, any purchase due more than 24h ago (SCHEDULED_PURCHASE_MAX_OVERDUE, #1549), so turning it on cannot buy a stale backlog. Set false to opt out." type = bool - default = false + default = true } variable "fire_scheduled_purchases_schedule" { diff --git a/terraform/modules/compute/recommendation_schedule_env_test.go b/terraform/modules/compute/recommendation_schedule_env_test.go index 443172d45..d43b7c08e 100644 --- a/terraform/modules/compute/recommendation_schedule_env_test.go +++ b/terraform/modules/compute/recommendation_schedule_env_test.go @@ -7,9 +7,13 @@ package compute_test import ( "os" + "path/filepath" "regexp" "strings" "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/api" ) const scheduleWhenEnabled = `var.enable_scheduled_tasks ? var.recommendation_schedule : ""` @@ -80,3 +84,69 @@ func TestScheduleEnvValuesShapes(t *testing.T) { t.Errorf("unrelated entry matched: %q", got) } } + +var tfvarsScheduleRe = regexp.MustCompile(`(?m)^\s*recommendation_schedule\s*=\s*"([^"]*)"`) + +// recommendationSchedules returns the recommendation_schedule values the +// deployment can end up with: each listed variables.tf default and each value +// a tfvars file matched by tfvarsGlobs sets, keyed by where it came from. +func recommendationSchedules(t *testing.T, varsFiles, tfvarsGlobs []string) map[string]string { + t.Helper() + got := map[string]string{} + for _, f := range varsFiles { + raw, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + body, ok := blockOnce(stripComments(string(raw)), `variable\s+"recommendation_schedule"\s*\{`) + m := tfStringDefaultRe.FindStringSubmatch(body) + if !ok || m == nil { + t.Fatalf("%s: no single recommendation_schedule variable with a string default", f) + } + got[f] = m[1] + } + for _, g := range tfvarsGlobs { + paths, err := filepath.Glob(g) + if err != nil { + t.Fatal(err) + } + for _, p := range paths { + raw, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + for _, m := range tfvarsScheduleRe.FindAllStringSubmatch(stripComments(string(raw)), -1) { + got[p] = m[1] + } + } + } + return got +} + +// Owner decision D32 (#756): AWS collects at 02:00 UTC like GCP and Azure, +// on a cron() schedule, so GET /api/recommendations/freshness can report +// next_collection_at. Under the old rate(1 day) default the API could not +// know the phase and always reported null on AWS. +func TestRecommendationScheduleDefaultsReportNextCollection(t *testing.T) { + now := time.Date(2026, 10, 27, 15, 4, 5, 0, time.UTC) + want := time.Date(2026, 10, 28, 2, 0, 0, 0, time.UTC) + schedules := recommendationSchedules(t, + []string{ + awsEnvDir + "/variables.tf", "aws/lambda/variables.tf", "aws/fargate/variables.tf", + gcpEnvDir + "/variables.tf", azureEnvDir + "/variables.tf", + }, + // Committed files only; a developer's untracked dev.tfvars may differ. + []string{awsEnvDir + "/github-*.tfvars", awsEnvDir + "/*.tfvars.example", "../../profiles/aws/*.tfvars.example"}, + ) + for _, env := range []string{"dev", "staging", "prod"} { + if _, ok := schedules[awsEnvDir+"/github-"+env+".tfvars"]; !ok { + t.Errorf("github-%s.tfvars no longer sets recommendation_schedule; update this test", env) + } + } + for src, expr := range schedules { + next := api.NewHandler(api.HandlerConfig{RecommendationSchedule: expr}).NextCollectionAt(now) + if next == nil || !next.Equal(want) { + t.Errorf("%s: recommendation_schedule %q gives next_collection_at %v; want %s (daily at 02:00 UTC)", src, expr, next, want) + } + } +} diff --git a/terraform/modules/compute/scheduled_task_cfn_test.go b/terraform/modules/compute/scheduled_task_cfn_test.go index ac61a9683..cf2f0477a 100644 --- a/terraform/modules/compute/scheduled_task_cfn_test.go +++ b/terraform/modules/compute/scheduled_task_cfn_test.go @@ -336,7 +336,10 @@ func TestCloudFormationScheduleCheckNegatives(t *testing.T) { return strings.Replace(s, `Input: '{"action": "finalize_revocations"}'`, `Input: '{"action": "analytics_collect"}'`, 1) }, "opt-in task on by default": func(s string) string { - return regexp.MustCompile(`(EnableFireScheduledPurchasesSchedule:\n Type: String\n Default: )"false"`).ReplaceAllString(s, `$1"true"`) + return regexp.MustCompile(`(EnableSendNotificationsSchedule:\n Type: String\n Default: )"false"`).ReplaceAllString(s, `$1"true"`) + }, + "fire sweep off by default": func(s string) string { + return regexp.MustCompile(`(EnableFireScheduledPurchasesSchedule:\n Type: String\n Default: )"true"`).ReplaceAllString(s, `$1"false"`) }, "default-on task off by default": func(s string) string { return regexp.MustCompile(`(EnableFinalizeRevocationsSchedule:\n Type: String\n Default: )"true"`).ReplaceAllString(s, `$1"false"`) diff --git a/terraform/modules/compute/scheduled_task_coverage_test.go b/terraform/modules/compute/scheduled_task_coverage_test.go index 44037d684..a5a7b8566 100644 --- a/terraform/modules/compute/scheduled_task_coverage_test.go +++ b/terraform/modules/compute/scheduled_task_coverage_test.go @@ -25,9 +25,11 @@ var exemptTasks = map[string]string{ // optInRequired tasks must be wired but default to off (no live purchasing or // retention change by default). Flipping cleanup to default-on later means -// removing it from this set. +// removing it from this set. fire_scheduled_purchases left it when the owner +// turned the fire sweep on by default (#712): it only executes purchases a +// user already approved for a later time, and the server refuses any row due +// more than 24h ago (#1549). process_scheduled_purchases stays opt-in. var optInRequired = map[string]bool{ - "fire_scheduled_purchases": true, "process_scheduled_purchases": true, "send_notifications": true, "cleanup": true, diff --git a/terraform/modules/compute/scheduled_task_env_test.go b/terraform/modules/compute/scheduled_task_env_test.go index 43020c127..c54834d6a 100644 --- a/terraform/modules/compute/scheduled_task_env_test.go +++ b/terraform/modules/compute/scheduled_task_env_test.go @@ -276,7 +276,7 @@ func envFixture(task, def string) (mod, env, compute string) { } func TestEnvChecksNegatives(t *testing.T) { - const cr, fire = "collect_recommendations", "fire_scheduled_purchases" + const cr, process = "collect_recommendations", "process_scheduled_purchases" type fixture struct { task, def string edit func(mod, env, compute string) (string, string, string) @@ -286,13 +286,13 @@ func TestEnvChecksNegatives(t *testing.T) { same := func(m, e, c string) (string, string, string) { return m, e, c } for name, f := range map[string]fixture{ "clean": {cr, "true", same, false, ""}, - "opt-in task opt-in": {fire, "false", same, false, ""}, - "opt-in task defaulting true": {fire, "true", same, true, ""}, + "opt-in task opt-in": {process, "false", same, false, ""}, + "opt-in task defaulting true": {process, "true", same, true, ""}, "default-on task defaulting off": {cr, "false", same, true, dimModuleDefault}, "default-on task off in the module only": {cr, "true", func(m, e, c string) (string, string, string) { return strings.Replace(m, "default = true", "default = false", 1), e, c }, false, dimModuleDefault}, - "opt-in task on in the module only": {fire, "false", func(m, e, c string) (string, string, string) { + "opt-in task on in the module only": {process, "false", func(m, e, c string) (string, string, string) { return strings.Replace(m, "default = false", "default = true", 1), e, c }, true, ""}, "module enable var without a default": {cr, "true", func(m, e, c string) (string, string, string) { @@ -340,7 +340,15 @@ func TestEnvChecksNegatives(t *testing.T) { envDefaultProblem("finalize_revocations", "true") != "" || envDefaultProblem("cleanup", "false") != "" { t.Error("default-on / opt-in policy not enforced") } - if got := tfvarsProblems("a.tfvars", "enable_finalize_revocations_schedule = false\nenable_fire_scheduled_purchases_schedule = false\n# enable_scheduled_tasks = false\n"); len(got) != 1 || !strings.Contains(got[0], "finalize_revocations") { + if !defaultOn("fire_scheduled_purchases") || defaultOn("process_scheduled_purchases") { + t.Error("the fire sweep is default-on and the process sweep opt-in (owner decision on #712)") + } + if got := tfvarsProblems("a.tfvars", "enable_finalize_revocations_schedule = false\nenable_process_scheduled_purchases_schedule = false\n# enable_scheduled_tasks = false\n"); len(got) != 1 || !strings.Contains(got[0], "finalize_revocations") { + t.Errorf("tfvarsProblems = %v", got) + } + // The fire sweep is default-on since the owner decision on #712, so a + // committed tfvars file switching it off is flagged like any other. + if got := tfvarsProblems("a.tfvars", "enable_fire_scheduled_purchases_schedule = false\n"); len(got) != 1 || !strings.Contains(got[0], "fire_scheduled_purchases") { t.Errorf("tfvarsProblems = %v", got) } if got := tfvarsProblems("a.tfvars", "enable_finalize_revocations_schedule = \"false\"\n"); len(got) != 1 { diff --git a/terraform/profiles/aws/dev.tfvars.example b/terraform/profiles/aws/dev.tfvars.example index 382a10d2d..fddd29805 100644 --- a/terraform/profiles/aws/dev.tfvars.example +++ b/terraform/profiles/aws/dev.tfvars.example @@ -54,4 +54,4 @@ frontend_price_class = "PriceClass_100" # Scheduled tasks enable_scheduled_tasks = true -recommendation_schedule = "rate(1 day)" +recommendation_schedule = "cron(0 2 * * ? *)" # daily at 02:00 UTC diff --git a/terraform/profiles/aws/fargate-dev.tfvars.example b/terraform/profiles/aws/fargate-dev.tfvars.example index 4fdbe0e49..854ed1d4a 100644 --- a/terraform/profiles/aws/fargate-dev.tfvars.example +++ b/terraform/profiles/aws/fargate-dev.tfvars.example @@ -50,4 +50,4 @@ frontend_price_class = "PriceClass_100" # Scheduled tasks enable_scheduled_tasks = true -recommendation_schedule = "rate(1 day)" +recommendation_schedule = "cron(0 2 * * ? *)" # daily at 02:00 UTC diff --git a/terraform/profiles/aws/prod.tfvars.example b/terraform/profiles/aws/prod.tfvars.example index b8e9980e9..b0f641849 100644 --- a/terraform/profiles/aws/prod.tfvars.example +++ b/terraform/profiles/aws/prod.tfvars.example @@ -54,4 +54,4 @@ frontend_price_class = "PriceClass_200" # Scheduled tasks enable_scheduled_tasks = true -recommendation_schedule = "rate(1 day)" +recommendation_schedule = "cron(0 2 * * ? *)" # daily at 02:00 UTC From e62d6011c72dc1ebffbd76622d61f19ea3a90791 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Wed, 28 Oct 2026 21:14:55 +0000 Subject: [PATCH 2/4] docs(changelog): note that bastion target roles must trust the bastion #1556 assumes a bastion account's target role with the bastion's own credentials and no longer falls back to the CUDly host's, so a target role that trusts the host instead of the bastion now gets AccessDenied until its trust policy names the bastion. Refs #712 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcad05353..93b9f094a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,13 @@ are the full record until the next release. actions, `ec2:DescribeRegions`, `rds:DescribeDBInstances`, and the new-style `es:*ReservedInstance*` OpenSearch actions (replacing the legacy `es:*ReservedElasticsearch*` names). +- **Bastion accounts whose target role trusts the CUDly host now get + AccessDenied.** CUDly assumes a bastion account's target role with the + bastion's own credentials and no longer falls back to the CUDly host's. + A target role whose trust policy names the CUDly host instead of the + bastion fails at AssumeRole until the bastion is added as the trusted + principal in that role's trust policy + ([#1556](https://github.com/LeanerCloud/cloud-commitments-platform/pull/1556)). ### Changed From b5d900f99d37880b2c2ff5b1754c384d41477e17 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Wed, 28 Oct 2026 21:18:48 +0000 Subject: [PATCH 3/4] revert(cfn): leave the CloudFormation install stack to its retirement #1432 (D29) deletes cloudformation/stacks/CUDly/ and scheduled_task_cfn_test.go, so the CloudFormation edits in this branch would only cause modify/delete conflicts. Restore both files to main and drop CloudFormation from the CHANGELOG entry. The fire sweep's default changes on the Terraform platforms only. Until #1432 lands, TestCloudFormationSchedulesEveryRequiredTask and the "opt-in task on by default" negative in that file fail on this branch alone, because the policy now treats fire as default-on and the retiring template still defaults it off; #1432 removes both tests. Refs #712 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 ++++----- cloudformation/stacks/CUDly/template.yaml | 6 ++---- terraform/modules/compute/scheduled_task_cfn_test.go | 5 +---- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b9f094a..6e45ac944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,14 +53,13 @@ are the full record until the next release. ### Changed - **The fire_scheduled_purchases sweep is now on by default on every - platform** (Lambda, Fargate, CloudFormation, Cloud Run, GKE, Container Apps - and AKS), every 15 minutes. It executes delayed auto-approved purchases once + Terraform platform** (Lambda, Fargate, Cloud Run, GKE, Container Apps and + AKS), every 15 minutes. It executes delayed auto-approved purchases once their scheduled time passes; until now they never ran unless the operator turned the sweep on. The server refuses (and sends the purchase-failed alert for) any purchase due more than 24h ago, so the first run cannot buy - a stale backlog. Set `enable_fire_scheduled_purchases_schedule = false` - (CloudFormation: `EnableFireScheduledPurchasesSchedule=false`) to opt out. - The process_scheduled_purchases sweep stays opt-in (#712) + a stale backlog. Set `enable_fire_scheduled_purchases_schedule = false` to + opt out. The process_scheduled_purchases sweep stays opt-in (#712) - **AWS recommendation collection moves to 02:00 UTC daily** (`recommendation_schedule = "cron(0 2 * * ? *)"`, was `rate(1 day)`), the same time as GCP and Azure. With a fixed time the recommendations freshness diff --git a/cloudformation/stacks/CUDly/template.yaml b/cloudformation/stacks/CUDly/template.yaml index 64f1ba5f7..c60f2a3f2 100644 --- a/cloudformation/stacks/CUDly/template.yaml +++ b/cloudformation/stacks/CUDly/template.yaml @@ -207,16 +207,14 @@ Parameters: EnableFireScheduledPurchasesSchedule: Type: String - Default: "true" + Default: "false" AllowedValues: - "true" - "false" Description: >- Run the fire_scheduled_purchases task on a schedule. It executes purchases in status scheduled whose scheduled time has passed (the - delayed auto-approve path), so it makes real purchases. On by default: - the server refuses, and alerts on, any purchase due more than 24h ago, - so turning it on cannot buy a stale backlog. Set "false" to opt out. + delayed auto-approve path), so it makes real purchases. Opt-in. FireScheduledPurchasesSchedule: Type: String diff --git a/terraform/modules/compute/scheduled_task_cfn_test.go b/terraform/modules/compute/scheduled_task_cfn_test.go index cf2f0477a..ac61a9683 100644 --- a/terraform/modules/compute/scheduled_task_cfn_test.go +++ b/terraform/modules/compute/scheduled_task_cfn_test.go @@ -336,10 +336,7 @@ func TestCloudFormationScheduleCheckNegatives(t *testing.T) { return strings.Replace(s, `Input: '{"action": "finalize_revocations"}'`, `Input: '{"action": "analytics_collect"}'`, 1) }, "opt-in task on by default": func(s string) string { - return regexp.MustCompile(`(EnableSendNotificationsSchedule:\n Type: String\n Default: )"false"`).ReplaceAllString(s, `$1"true"`) - }, - "fire sweep off by default": func(s string) string { - return regexp.MustCompile(`(EnableFireScheduledPurchasesSchedule:\n Type: String\n Default: )"true"`).ReplaceAllString(s, `$1"false"`) + return regexp.MustCompile(`(EnableFireScheduledPurchasesSchedule:\n Type: String\n Default: )"false"`).ReplaceAllString(s, `$1"true"`) }, "default-on task off by default": func(s string) string { return regexp.MustCompile(`(EnableFinalizeRevocationsSchedule:\n Type: String\n Default: )"true"`).ReplaceAllString(s, `$1"false"`) From 8bd7095df206011d2e18302d1a845c10bc3e4541 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Wed, 28 Oct 2026 21:20:25 +0000 Subject: [PATCH 4/4] test(iac): give the schedule-default test its own default regex TestRecommendationScheduleDefaultsReportNextCollection borrowed tfStringDefaultRe from scheduled_task_cfn_test.go, which #1432 deletes; the combined tree would not compile. Declare a local copy instead. Refs #712 Refs #756 Co-Authored-By: Claude Opus 5.5 --- .../modules/compute/recommendation_schedule_env_test.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/terraform/modules/compute/recommendation_schedule_env_test.go b/terraform/modules/compute/recommendation_schedule_env_test.go index d43b7c08e..59f2820da 100644 --- a/terraform/modules/compute/recommendation_schedule_env_test.go +++ b/terraform/modules/compute/recommendation_schedule_env_test.go @@ -85,7 +85,12 @@ func TestScheduleEnvValuesShapes(t *testing.T) { } } -var tfvarsScheduleRe = regexp.MustCompile(`(?m)^\s*recommendation_schedule\s*=\s*"([^"]*)"`) +var ( + tfvarsScheduleRe = regexp.MustCompile(`(?m)^\s*recommendation_schedule\s*=\s*"([^"]*)"`) + // Own copy rather than tfStringDefaultRe in scheduled_task_cfn_test.go, + // which goes away with the CloudFormation stack (#1432). + scheduleDefaultRe = regexp.MustCompile(`(?m)^\s*default\s*=\s*"([^"]*)"`) +) // recommendationSchedules returns the recommendation_schedule values the // deployment can end up with: each listed variables.tf default and each value @@ -99,7 +104,7 @@ func recommendationSchedules(t *testing.T, varsFiles, tfvarsGlobs []string) map[ t.Fatal(err) } body, ok := blockOnce(stripComments(string(raw)), `variable\s+"recommendation_schedule"\s*\{`) - m := tfStringDefaultRe.FindStringSubmatch(body) + m := scheduleDefaultRe.FindStringSubmatch(body) if !ok || m == nil { t.Fatalf("%s: no single recommendation_schedule variable with a string default", f) }