Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -599,16 +599,16 @@ Each cloud provider has lifecycle policies configured via Terraform (`terraform/

### Database Cleanup Jobs

The server's `cleanup` task deletes expired sessions and, via `CleanupOldExecutions(30)`, purchase executions that are completed with `scheduled_date` older than 30 days, canceled with `updated_at` older than 30 days, or in any status other than failed or canceled with `expires_at` older than 30 days. Failed executions are kept. Azure Container Apps schedules it by default; AWS Lambda and CloudFormation can schedule it on request:
The server's `cleanup` task deletes expired sessions and, via `CleanupOldExecutions(30)`, purchase executions that are completed with `scheduled_date` older than 30 days, canceled with `updated_at` older than 30 days, or in any status other than failed or canceled with `expires_at` older than 30 days. Failed executions are kept. Azure Container Apps schedules it by default; AWS Lambda, AWS Fargate and CloudFormation can schedule it on request:

- **Azure Container Apps (when `enable_scheduled_tasks` is true):** a Logic App calls `/api/scheduled/cleanup` daily (`terraform/modules/compute/azure/container-apps/scheduled-tasks.tf`)
- **AWS Lambda (opt-in):** set `enable_cleanup_schedule = true` (default false; `cleanup_schedule` defaults to `rate(1 day)`) in `terraform/environments/aws`. The first run deletes the whole backlog older than 30 days, so export any history you want to keep first.
- **CloudFormation (opt-in):** set the `EnableCleanupSchedule` stack parameter to `true` (default `false`; `CleanupSchedule` defaults to `rate(1 day)`) on `cloudformation/stacks/CUDly`. The same backlog warning applies.
- **AWS Fargate:** nothing schedules it yet. The `terraform/modules/compute/aws/cleanup-lambda/` module is not consumed by any environment.
- **AWS Fargate (opt-in):** the same `enable_cleanup_schedule` / `cleanup_schedule` variables in `terraform/environments/aws` add a `<stack>-fargate-sweep-cleanup` EventBridge rule that runs `./cudly --task cleanup` as a one-off ECS task (`terraform/modules/compute/aws/fargate/sweeps.tf`). The same backlog warning applies. (The `terraform/modules/compute/aws/cleanup-lambda/` module is not consumed by any environment.)
- **GCP:** nothing schedules it. The `terraform/modules/compute/gcp/cleanup-function/` module is not consumed by any environment.
- **AKS (opt-in):** set `enable_cleanup_schedule = true` (default false; `cleanup_schedule` defaults to `0 3 * * *`) on the AKS module. Like the module's other scheduled tasks, it runs as a Kubernetes CronJob (`terraform/modules/compute/azure/aks/scheduled-tasks.tf`) only when `deploy_kubernetes_resources` is true.

Scheduling for AWS Fargate, CloudFormation and GCP is tracked by #712.
Scheduling for GCP is tracked by #712.

Alternatively, use pg_cron for database-native scheduling.

Expand All @@ -618,8 +618,9 @@ CUDly learns that an RI listed on the AWS Reserved Instance Marketplace sold, cl

- **AWS Lambda:** hourly by default. `enable_finalize_revocations_schedule` (default `true`) and `finalize_revocations_schedule` (default `rate(1 hour)`) in `terraform/environments/aws`.
- **CloudFormation:** hourly by default. The `EnableFinalizeRevocationsSchedule` (default `true`) and `FinalizeRevocationsSchedule` (default `rate(1 hour)`) stack parameters on `cloudformation/stacks/CUDly`.
- **AWS Fargate:** hourly by default, as a one-off ECS task. The same `enable_finalize_revocations_schedule` (default `true`) and `finalize_revocations_schedule` (default `rate(1 hour)`) variables in `terraform/environments/aws`.
- **AKS:** hourly by default, as a Kubernetes CronJob, when `deploy_kubernetes_resources` is true. `enable_finalize_revocations_schedule` (default `true`) and `finalize_revocations_schedule` (default `30 * * * *`) on the AKS module.
- **AWS Fargate, GCP Cloud Run, GKE and Azure Container Apps:** nothing schedules `finalize_revocations`, so the poll does not run and listings stay as the list and cancel endpoints recorded them. Until #712 wires the task there, call `POST /api/scheduled/finalize_revocations` (authenticated like the other `/api/scheduled/` tasks) from your own scheduler, about hourly.
- **GCP Cloud Run, GKE and Azure Container Apps:** nothing schedules `finalize_revocations`, so the poll does not run and listings stay as the list and cancel endpoints recorded them. Until #712 wires the task there, call `POST /api/scheduled/finalize_revocations` (authenticated like the other `/api/scheduled/` tasks) from your own scheduler, about hourly.

---

Expand Down
7 changes: 5 additions & 2 deletions known_issues/33_executions_retention_cleanup.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
> schedules cleanup on AWS. The same holds for the Terraform behind the
> "matches what the cleanup-lambda Terraform actually configures" sentence
> under "Original problem". Only Azure Container Apps schedules cleanup today. Retention
> scheduling is tracked by #712; retention is scheduled via the server `cleanup` task on AWS Lambda and CloudFormation when opted in.
> scheduling is tracked by #712; retention is scheduled via the server `cleanup` task on AWS Lambda, AWS Fargate and CloudFormation when opted in.

## Resolution

Expand All @@ -21,6 +21,9 @@
`terraform/modules/compute/aws/cleanup-lambda/main.tf:98`~~ (unconsumed module, see amendment)
- **AWS Lambda (opt-in):** the `enable_cleanup_schedule` / `cleanup_schedule`
sweep `<stack>-sweep-cleanup` runs the server cleanup task.
- **AWS Fargate (opt-in):** the same `enable_cleanup_schedule` /
`cleanup_schedule` variables add a `<stack>-fargate-sweep-cleanup` rule
that runs `./cudly --task cleanup` as a one-off ECS task.
- **CloudFormation (opt-in):** the `EnableCleanupSchedule` /
`CleanupSchedule` stack parameters add a `<stack>-Cleanup` rule that runs
the server cleanup task.
Expand All @@ -34,7 +37,7 @@
The `CleanupOldExecutions` Postgres implementation in
`internal/config/store_postgres.go` runs the expected
`DELETE FROM purchase_executions WHERE …`, so the table is bounded
only where that task is scheduled (Azure Container Apps by default, AWS Lambda and CloudFormation when opted in, see amendment).
only where that task is scheduled (Azure Container Apps by default, AWS Lambda, AWS Fargate and CloudFormation when opted in, see amendment).

## Original problem (historical context)

Expand Down
14 changes: 14 additions & 0 deletions terraform/environments/aws/compute.tf
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,20 @@ module "compute_fargate" {
enable_fire_scheduled_purchases_schedule = var.enable_fire_scheduled_purchases_schedule
fire_scheduled_purchases_schedule = var.fire_scheduled_purchases_schedule

# Sweeps (sweeps.tf); process, notifications and cleanup are opt-in
enable_process_scheduled_purchases_schedule = var.enable_process_scheduled_purchases_schedule
process_scheduled_purchases_schedule = var.process_scheduled_purchases_schedule
enable_send_notifications_schedule = var.enable_send_notifications_schedule
send_notifications_schedule = var.send_notifications_schedule
enable_finalize_revocations_schedule = var.enable_finalize_revocations_schedule
finalize_revocations_schedule = var.finalize_revocations_schedule
enable_reap_stuck_purchases_schedule = var.enable_reap_stuck_purchases_schedule
reap_stuck_purchases_schedule = var.reap_stuck_purchases_schedule
enable_analytics_collect_schedule = var.enable_analytics_collect_schedule
analytics_collect_schedule = var.analytics_collect_schedule
enable_cleanup_schedule = var.enable_cleanup_schedule
cleanup_schedule = var.cleanup_schedule

# ECS Exec for debugging
enable_execute_command = var.fargate_enable_execute_command

Expand Down
137 changes: 137 additions & 0 deletions terraform/modules/compute/aws/fargate/sweeps.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Additional scheduled sweeps (#712). Each enabled entry becomes an
# EventBridge rule whose target runs a one-off copy of the app task with
# `./cudly --task <map key>`, the same entry point the recommendations, RI
# exchange, ladder and fire targets in main.tf use. Task names, schedule
# variables and enable defaults match the Lambda module: reap_stuck_purchases,
# analytics_collect and finalize_revocations are on by default;
# process_scheduled_purchases, send_notifications and cleanup are opt-in
# (default off), so no deployment starts proposing purchases, sending
# notification emails or deleting execution history unless it asks to.
#
# `rule` is the rule-name suffix: rule names are
# "<stack_name>-fargate-sweep-<rule>" (longest suffix "-sweep-analytics").
# Retries stay off, as on the other ECS targets in main.tf; a missed run is
# picked up by the next tick, and an overlapping run is a no-op under the
# server's per-task advisory lock.
locals {
sweep_tasks = {
reap_stuck_purchases = { rule = "reap", enabled = var.enable_reap_stuck_purchases_schedule, schedule = var.reap_stuck_purchases_schedule }
analytics_collect = { rule = "analytics", enabled = var.enable_analytics_collect_schedule, schedule = var.analytics_collect_schedule }
process_scheduled_purchases = { rule = "process", enabled = var.enable_process_scheduled_purchases_schedule, schedule = var.process_scheduled_purchases_schedule }
send_notifications = { rule = "notify", enabled = var.enable_send_notifications_schedule, schedule = var.send_notifications_schedule }
finalize_revocations = { rule = "finalize", enabled = var.enable_finalize_revocations_schedule, schedule = var.finalize_revocations_schedule }
cleanup = { rule = "cleanup", enabled = var.enable_cleanup_schedule, schedule = var.cleanup_schedule }
}
active_sweeps = { for k, v in local.sweep_tasks : k => v if v.enabled }
}

resource "aws_cloudwatch_event_rule" "sweep" {
for_each = local.active_sweeps

name = "${local.name_prefix}-sweep-${each.value.rule}"
description = "Trigger the ${each.key} scheduled task"
schedule_expression = each.value.schedule

tags = local.common_tags
}

resource "aws_cloudwatch_event_target" "sweep" {
for_each = local.active_sweeps

rule = aws_cloudwatch_event_rule.sweep[each.key].name
target_id = "ecs-task"
arn = aws_ecs_cluster.main.arn
role_arn = aws_iam_role.eventbridge_sweeps[0].arn

ecs_target {
task_count = 1
task_definition_arn = aws_ecs_task_definition.main.arn
launch_type = "FARGATE"
platform_version = "LATEST"

network_configuration {
subnets = var.private_subnet_ids
security_groups = [aws_security_group.ecs_tasks.id]
assign_public_ip = false
}
}

retry_policy {
maximum_retry_attempts = 0
maximum_event_age_in_seconds = 3600
}

input = jsonencode({
containerOverrides = [{
name = "app"
command = ["./cudly", "--task", each.key]
}]
})
}

# One invoker role for all sweeps: every sweep runs the same task definition
# in the same cluster, so per-task roles would carry identical policies. Same
# scoping as the other invoker roles in main.tf (#292): RunTask only on the
# module's task definition in the module's cluster, PassRole only for the
# task and task-execution roles and only to ECS tasks.
resource "aws_iam_role" "eventbridge_sweeps" {
count = length(local.active_sweeps) > 0 ? 1 : 0

name = "${local.name_prefix}-eb-sweeps"
permissions_boundary = var.permissions_boundary_arn

assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Service = "events.amazonaws.com"
}
Action = "sts:AssumeRole"
}
]
})

tags = local.common_tags
}

resource "aws_iam_role_policy" "eventbridge_sweeps" {
count = length(local.active_sweeps) > 0 ? 1 : 0

name = "ecs-run-task"
role = aws_iam_role.eventbridge_sweeps[0].id

policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"ecs:RunTask"
]
Resource = aws_ecs_task_definition.main.arn
Condition = {
ArnEquals = {
"ecs:cluster" = aws_ecs_cluster.main.arn
}
}
},
{
Effect = "Allow"
Action = [
"iam:PassRole"
]
Resource = [
aws_iam_role.task_execution.arn,
aws_iam_role.task.arn
]
Condition = {
StringEquals = {
"iam:PassedToService" = "ecs-tasks.amazonaws.com"
}
}
}
]
})
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# The four EventBridge invoker roles (#292). Each may run exactly the module's
# The five EventBridge invoker roles (#292, plus the shared sweeps role of #712). Each may run exactly the module's
# task definition, only in the module's cluster, and may pass the task and
# task-execution roles only to ECS tasks. Without the two conditions an actor
# who can create EventBridge targets in the account can run the task definition
Expand All @@ -10,7 +10,7 @@
# overrides give those resources distinct, well-formed ARNs: the provider
# validates ARN-typed arguments, and two roles sharing one mock ARN would let
# a PassRole statement naming only one of them pass. Roles not overridden
# (the four invoker roles) share the default, which nothing asserts on.
# (the five invoker roles) share the default, which nothing asserts on.
mock_provider "aws" {
mock_resource "aws_iam_role" {
defaults = { arn = "arn:aws:iam::123456789012:role/cudly-prod-abcd1234-fargate-mock" }
Expand Down Expand Up @@ -136,4 +136,21 @@ run "invoker_roles_are_scoped" {
] == [[toset([aws_iam_role.task_execution.arn, aws_iam_role.task.arn]), "ecs-tasks.amazonaws.com"]]
error_message = "eventbridge_fire_scheduled_purchases: iam:PassRole must be one statement on the task and task-execution roles, conditioned on StringEquals iam:PassedToService = ecs-tasks.amazonaws.com."
}

# sweeps (one role for every sweep in sweeps.tf; reap, analytics_collect
# and finalize_revocations are on by default, so the role exists here)
assert {
condition = [
for s in jsondecode(aws_iam_role_policy.eventbridge_sweeps[0].policy).Statement :
[s.Resource, try(s.Condition.ArnEquals["ecs:cluster"], null)] if contains(s.Action, "ecs:RunTask")
] == [[aws_ecs_task_definition.main.arn, aws_ecs_cluster.main.arn]]
error_message = "eventbridge_sweeps: ecs:RunTask must be one statement on the module's task definition, conditioned on ArnEquals ecs:cluster = the module's cluster."
}
assert {
condition = [
for s in jsondecode(aws_iam_role_policy.eventbridge_sweeps[0].policy).Statement :
[toset(s.Resource), try(s.Condition.StringEquals["iam:PassedToService"], null)] if contains(s.Action, "iam:PassRole")
] == [[toset([aws_iam_role.task_execution.arn, aws_iam_role.task.arn]), "ecs-tasks.amazonaws.com"]]
error_message = "eventbridge_sweeps: iam:PassRole must be one statement on the task and task-execution roles, conditioned on StringEquals iam:PassedToService = ecs-tasks.amazonaws.com."
}
}
Loading
Loading