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 592c1cc9..fcad0535 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 c60f2a3f..64f1ba5f 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 284546c5..68096aab 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 7386ac43..df9013f6 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 225468c6..c2b021e7 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 40ca2097..3fb9098c 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 6f15a1cd..84f66882 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 ee816cd4..4ee59ab4 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 015e6043..9bddcef0 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 9f0d4cea..477f9f3d 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 e132ee44..06034bbb 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 3d956430..62a8873e 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 3d89dc4d..55744b93 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 3ab5f07a..91968553 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 716efc18..c6724f57 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 79c87e20..8865b994 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 01cb5480..27bad22b 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 0aaf890e..2eb123f3 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 0f235a6a..c29cafd2 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 2ee65fe8..7e8eb131 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 ad9e3269..4e39ed95 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 086fa652..c16bc6dc 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 20c3c8b8..a42d5881 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 f41e1eb1..e995ef00 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 fa439115..defe6199 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 f3fab566..0f11bd65 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 836948d3..0d1cea34 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 443172d4..d43b7c08 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 ac61a968..cf2f0477 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 44037d68..a5a7b856 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 43020c12..c54834d6 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 382a10d2..fddd2980 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 4fdbe0e4..854ed1d4 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 b8e9980e..b0f64184 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 fcad0535..93b9f094 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 93b9f094..6e45ac94 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 64f1ba5f..c60f2a3f 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 cf2f0477..ac61a968 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 d43b7c08..59f2820d 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) }