diff --git a/CHANGELOG.md b/CHANGELOG.md index f7284ef2e..d3a8ebe2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,29 @@ 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 + +- **The fire_scheduled_purchases sweep is now on by default on every + 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` 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) ### Removed 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 782e7c902..b16ded680 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 49769a332..770799b30 100644 --- a/terraform/modules/compute/aws/lambda/variables.tf +++ b/terraform/modules/compute/aws/lambda/variables.tf @@ -192,9 +192,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" { @@ -246,9 +246,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..59f2820da 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,74 @@ func TestScheduleEnvValuesShapes(t *testing.T) { t.Errorf("unrelated entry matched: %q", got) } } + +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 +// 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 := scheduleDefaultRe.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_coverage_test.go b/terraform/modules/compute/scheduled_task_coverage_test.go index f9f38ca48..e7b6fd9e3 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