diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59eec219f..6d8fc592c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1536,8 +1536,8 @@ jobs: - name: Run parity script self-tests run: bash scripts/test-azure-role-parity.sh - # Assert that the AWS IAM action lists are identical across the CFN stack, - # the TF lambda/fargate modules, and the federation CFN/TF/CLI templates. + # Assert that the AWS IAM action lists are identical across the TF + # lambda/fargate modules and across the federation CFN/TF/CLI templates. # Fast (shell only), so it always runs. aws-iam-parity: name: AWS IAM actions parity (CFN vs TF) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 838751ac9..af0f9138c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -163,9 +163,9 @@ repos: # with the version pinned in additional_dependencies, like zizmor above. # # --non-zero-exit-code error: errors (templates CloudFormation rejects) - # fail the hook. Warnings do not fail it, because the - # cloudformation/stacks/CUDly and aws-cross-account templates carry a W3005 - # (redundant DependsOn) that is not a load or deploy failure. + # fail the hook. Warnings do not fail it, because the aws-cross-account + # template carries a W3005 (redundant DependsOn) that is not a load or + # deploy failure. - repo: local hooks: - id: cfn-lint diff --git a/CHANGELOG.md b/CHANGELOG.md index 592c1cc9f..f7284ef2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,16 @@ are the full record until the next release. new-style `es:*ReservedInstance*` OpenSearch actions (replacing the legacy `es:*ReservedElasticsearch*` names). +### Removed + +- The CloudFormation install stack (`cloudformation/stacks/CUDly`). It still + created DynamoDB tables and set no `DB_HOST`, so the Postgres-only server it + deployed failed at startup and the stack could not serve any request or + scheduled task. Install on AWS with Terraform (`terraform/environments/aws`, + Lambda or Fargate) instead; see `docs/DEPLOYMENT.md`. The target-account role + templates (`cloudformation/stacks/CUDly-CrossAccount` and the federation + CloudFormation templates under `iac/federation/`) are unchanged (#1432) + ### Fixed - Account scopes match `allowed_accounts` entries by account ID only. A diff --git a/CLAUDE.md b/CLAUDE.md index fc72ab16e..251f98bcd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,8 @@ - `internal/`: backend application code (API, auth, purchase, scheduler, ...) - `frontend/`: TypeScript web frontend (webpack + jest) - `terraform/`, `cloudformation/`, `arm/`, `iac/`: infrastructure as code + (Terraform is the only install path; the CloudFormation and ARM templates + create CUDly's role in target accounts) - `docs/`: documentation and markdown files - `scripts/`: utility scripts - `tests/`: end-to-end tests (Go unit tests live next to the code they test) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c957babc6..32e8a7d2a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -201,7 +201,8 @@ cloud-commitments-platform/ ├── internal/ # application code (API, auth, purchase, scheduler, ...) ├── iac/ # infrastructure-as-code helpers ├── frontend/ # web dashboard (TypeScript) -├── terraform/, cloudformation/, arm/ # deployment definitions +├── terraform/ # deployment definitions (the only install path) +├── cloudformation/, arm/ # target-account role templates ├── tests/e2e/ # black-box suite (separate stdlib-only module) ├── known_issues/ # tracked tech debt and deferred fixes ├── scripts/ # Repository hook and helper scripts diff --git a/README.md b/README.md index 81adc1369..d557f3951 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ Per-account service overrides are available from **Settings -> Accounts**. Expan - `cmd/lambda` contains the Lambda entry point. - `internal` contains platform services and API code. - `frontend` contains the web dashboard. -- `terraform`, `cloudformation`, `arm`, and related infrastructure directories contain deployment definitions. +- `terraform` contains the deployment definitions; install CUDly with it (see [deployment](docs/DEPLOYMENT.md)). There is no CloudFormation install. +- `cloudformation`, `arm`, and `iac` contain the templates that create CUDly's role in each target cloud account. They do not install CUDly. This component does not provide the `cudly` CLI. Use the [CLI component](https://github.com/LeanerCloud/cloud-commitments-cli) for command-line workflows. diff --git a/cloudformation/stacks/CUDly/template.yaml b/cloudformation/stacks/CUDly/template.yaml deleted file mode 100644 index c60f2a3f2..000000000 --- a/cloudformation/stacks/CUDly/template.yaml +++ /dev/null @@ -1,1302 +0,0 @@ -# Copyright (c) 2024 LeanerCloud -# CUDly - Multi-Cloud Commitment & Usage Discount Manager - -AWSTemplateFormatVersion: "2010-09-09" -Description: "CUDly: Automated Reserved Instance and Savings Plans purchase manager for AWS, Azure, and GCP" - -Metadata: - AWS::CloudFormation::Interface: - ParameterGroups: - - Label: - default: Notifications - Parameters: - - EmailAddress - - NotificationDaysBeforePurchase - - - Label: - default: Purchase Defaults - Parameters: - - DefaultTerm - - DefaultPaymentOption - - DefaultCoverage - - DefaultRampSchedule - - - Label: - default: Cost Dashboard - Parameters: - - EnableDashboard - - DashboardDomainName - - DashboardHostedZoneId - - - Label: - default: Deployment Configuration - Parameters: - - LambdaImageUri - - CpuArchitecture - - LambdaMemorySize - - LogRetentionPeriod - - ExecutionFrequency - - - Label: - default: Scheduled Tasks - Parameters: - - EnableFinalizeRevocationsSchedule - - FinalizeRevocationsSchedule - - EnableAnalyticsCollectSchedule - - AnalyticsCollectSchedule - - EnableReapStuckPurchasesSchedule - - ReapStuckPurchasesSchedule - - EnableFireScheduledPurchasesSchedule - - FireScheduledPurchasesSchedule - - EnableSendNotificationsSchedule - - SendNotificationsSchedule - - EnableCleanupSchedule - - CleanupSchedule - - ParameterLabels: - EmailAddress: - default: Notification email address - EnableDashboard: - default: Enable the web dashboard? - DashboardDomainName: - default: Custom domain for dashboard (optional) - DashboardHostedZoneId: - default: Route53 Hosted Zone ID for custom domain - -Parameters: - EmailAddress: - Type: String - Default: user@example.com - Description: Email address for purchase notifications and savings reports - - NotificationDaysBeforePurchase: - Type: Number - Default: 3 - MinValue: 1 - MaxValue: 14 - Description: Days before scheduled purchase to send notification email - - DefaultTerm: - Type: Number - Default: 3 - AllowedValues: - - 1 - - 3 - Description: Default commitment term in years - - DefaultPaymentOption: - Type: String - Default: no-upfront - AllowedValues: - - all-upfront - - partial-upfront - - no-upfront - Description: Default payment option for commitments - - DefaultCoverage: - Type: Number - Default: 80 - MinValue: 0 - MaxValue: 100 - Description: Default percentage of recommendations to purchase - - DefaultRampSchedule: - Type: String - Default: immediate - AllowedValues: - - immediate - - weekly-25pct - - monthly-10pct - Description: Default ramp-up schedule for gradual adoption - - EnableDashboard: - Type: String - Default: "true" - AllowedValues: - - "true" - - "false" - Description: Enable the CUDly web dashboard - - DashboardDomainName: - Type: String - Default: "" - Description: Optional custom domain for the dashboard (e.g., cudly.example.com) - - DashboardHostedZoneId: - Type: String - Default: "" - Description: Route53 Hosted Zone ID for DNS validation (required if using custom domain) - - LambdaImageUri: - Type: String - Description: Full Docker image URI for the Lambda function (account.dkr.ecr.region.amazonaws.com/repo:tag) - - CpuArchitecture: - Type: String - Default: x86_64 - AllowedValues: - - x86_64 - - arm64 - Description: CPU architecture for Lambda function - - LambdaMemorySize: - Type: Number - Default: 512 - MinValue: 128 - MaxValue: 3008 - Description: Memory allocated to Lambda function (MB) - - LogRetentionPeriod: - Type: Number - Default: 30 - Description: CloudWatch Logs retention period (days) - - ExecutionFrequency: - Type: String - Default: "rate(1 day)" - Description: How often to check for new recommendations - - # Scheduled server tasks (#712). Defaults and cadences match the Terraform - # Lambda module (terraform/modules/compute/aws/lambda/variables.tf) except - # where noted. Opt-in tasks start purchases or retry them, so each stack - # enables them deliberately. - EnableFinalizeRevocationsSchedule: - Type: String - Default: "true" - AllowedValues: - - "true" - - "false" - Description: Run the finalize_revocations task on a schedule (idempotent audit repair of revoked purchases, plus the RI Marketplace listing poll, which has no schedule of its own; disabling this also stops recording listing sales and proceeds) - - FinalizeRevocationsSchedule: - Type: String - Default: "rate(1 hour)" - Description: EventBridge schedule expression for finalize_revocations - - EnableAnalyticsCollectSchedule: - Type: String - Default: "true" - AllowedValues: - - "true" - - "false" - Description: Run the analytics_collect task on a schedule (savings snapshot, partition maintenance, retention and view refresh) - - AnalyticsCollectSchedule: - Type: String - Default: "rate(1 day)" - Description: EventBridge schedule expression for analytics_collect - - EnableReapStuckPurchasesSchedule: - Type: String - Default: "false" - AllowedValues: - - "true" - - "false" - Description: >- - Run the reap_stuck_purchases task on a schedule. It marks executions stuck - in running (after 10 minutes) or approved (after 1h20m) as failed. Off by - default on this stack, unlike the Terraform Lambda module: the - PurchaseCheck rule runs process_scheduled_purchases only daily, so an - approved execution stranded by a crash would be failed before the next - process run re-drives it (#894). - - ReapStuckPurchasesSchedule: - Type: String - Default: "rate(5 minutes)" - Description: EventBridge schedule expression for reap_stuck_purchases - - EnableFireScheduledPurchasesSchedule: - Type: String - 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. Opt-in. - - FireScheduledPurchasesSchedule: - Type: String - Default: "rate(15 minutes)" - Description: EventBridge schedule expression for fire_scheduled_purchases - - EnableSendNotificationsSchedule: - Type: String - Default: "false" - AllowedValues: - - "true" - - "false" - Description: Run the send_notifications task on a schedule (upcoming-purchase emails). Opt-in. - - SendNotificationsSchedule: - Type: String - Default: "rate(1 day)" - Description: EventBridge schedule expression for send_notifications - - EnableCleanupSchedule: - Type: String - Default: "false" - AllowedValues: - - "true" - - "false" - Description: >- - Run the server cleanup task on a schedule. Opt-in. It 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 a status outside failed/canceled - with expires_at older than 30 days. The first run deletes the whole - backlog, so export any history you want to keep first. - - CleanupSchedule: - Type: String - Default: "rate(1 day)" - Description: EventBridge schedule expression for cleanup - - CrossAccountTargetAccountIds: - Type: CommaDelimitedList - Default: "" - AllowedPattern: "^$|^[0-9]{12}$" - Description: >- - Comma-separated AWS account IDs this stack itself calls sts:AssumeRole - against, for multi-account plan execution. Empty (the default) creates no - cross-account grant at all. There is no "any account" value: the previous - unconditional grant let the hub assume any CUDly* role in any AWS - account, so a mis-selected account produced a successful purchase in the - wrong account rather than an AccessDenied (#1636). For bastion-mode - accounts list the bastion's account, not the target's, because only the - first hop runs on this stack's identity. UPGRADING AN EXISTING STACK WITHOUT - SETTING THIS REMOVES THE GRANT, and multi-account collection then fails - with AccessDenied until the accounts are listed. - -Conditions: - DeployDashboard: - Fn::Equals: - - Ref: EnableDashboard - - "true" - - HasCustomDomain: - Fn::And: - - Condition: DeployDashboard - - Fn::Not: - - Fn::Equals: - - Ref: DashboardDomainName - - "" - - Fn::Not: - - Fn::Equals: - - Ref: DashboardHostedZoneId - - "" - - Arm64: - Fn::Equals: - - Ref: CpuArchitecture - - arm64 - - # Joining the list back to a string is how a CommaDelimitedList is tested for - # emptiness: the Default of "" arrives as a one-element list holding "", which - # is not equal to an empty list and cannot be compared to one. - HasCrossAccountTargets: - Fn::Not: - - Fn::Equals: - - Fn::Join: - - "" - - Ref: CrossAccountTargetAccountIds - - "" - - ScheduleFinalizeRevocations: - Fn::Equals: - - Ref: EnableFinalizeRevocationsSchedule - - "true" - - ScheduleAnalyticsCollect: - Fn::Equals: - - Ref: EnableAnalyticsCollectSchedule - - "true" - - ScheduleReapStuckPurchases: - Fn::Equals: - - Ref: EnableReapStuckPurchasesSchedule - - "true" - - ScheduleFireScheduledPurchases: - Fn::Equals: - - Ref: EnableFireScheduledPurchasesSchedule - - "true" - - ScheduleSendNotifications: - Fn::Equals: - - Ref: EnableSendNotificationsSchedule - - "true" - - ScheduleCleanup: - Fn::Equals: - - Ref: EnableCleanupSchedule - - "true" - -Resources: - # ============================================================================= - # DynamoDB Tables - # ============================================================================= - - ConfigTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: !Sub "${AWS::StackName}-Config" - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: SK - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - - AttributeName: SK - KeyType: RANGE - SSESpecification: - SSEEnabled: true - PointInTimeRecoverySpecification: - PointInTimeRecoveryEnabled: true - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-Config" - - PurchasePlansTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: !Sub "${AWS::StackName}-PurchasePlans" - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: SK - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - - AttributeName: SK - KeyType: RANGE - SSESpecification: - SSEEnabled: true - PointInTimeRecoverySpecification: - PointInTimeRecoveryEnabled: true - TimeToLiveSpecification: - AttributeName: ttl - Enabled: true - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-PurchasePlans" - - PurchaseHistoryTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: !Sub "${AWS::StackName}-PurchaseHistory" - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: SK - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - - AttributeName: SK - KeyType: RANGE - SSESpecification: - SSEEnabled: true - PointInTimeRecoverySpecification: - PointInTimeRecoveryEnabled: true - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-PurchaseHistory" - - UsersTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: !Sub "${AWS::StackName}-Users" - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: Email - AttributeType: S - - AttributeName: Role - AttributeType: S - - AttributeName: PasswordResetToken - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - SSESpecification: - SSEEnabled: true - PointInTimeRecoverySpecification: - PointInTimeRecoveryEnabled: true - GlobalSecondaryIndexes: - - IndexName: EmailIndex - KeySchema: - - AttributeName: Email - KeyType: HASH - Projection: - ProjectionType: ALL - - IndexName: RoleIndex - KeySchema: - - AttributeName: Role - KeyType: HASH - Projection: - ProjectionType: KEYS_ONLY - - IndexName: ResetTokenIndex - KeySchema: - - AttributeName: PasswordResetToken - KeyType: HASH - Projection: - ProjectionType: ALL - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-Users" - - GroupsTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: !Sub "${AWS::StackName}-Groups" - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - SSESpecification: - SSEEnabled: true - PointInTimeRecoverySpecification: - PointInTimeRecoveryEnabled: true - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-Groups" - - SessionsTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: !Sub "${AWS::StackName}-Sessions" - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: UserID - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - SSESpecification: - SSEEnabled: true - PointInTimeRecoverySpecification: - PointInTimeRecoveryEnabled: true - GlobalSecondaryIndexes: - - IndexName: UserIDIndex - KeySchema: - - AttributeName: UserID - KeyType: HASH - Projection: - ProjectionType: KEYS_ONLY - TimeToLiveSpecification: - AttributeName: ExpiresAtEpoch - Enabled: true - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-Sessions" - - # ============================================================================= - # Secrets Manager - # ============================================================================= - - APIKeySecret: - Type: AWS::SecretsManager::Secret - Properties: - Name: !Sub "${AWS::StackName}-APIKey" - Description: API Key for CUDly Dashboard authentication - GenerateSecretString: - PasswordLength: 32 - ExcludePunctuation: true - - # ============================================================================= - # Note: ECR Repository is created by the deploy CLI before stack creation - # to allow pushing the Lambda image first - # ============================================================================= - - # ============================================================================= - # Lambda Execution Role - # ============================================================================= - - LambdaExecutionRole: - Type: AWS::IAM::Role - Properties: - RoleName: !Sub "${AWS::StackName}-LambdaRole" - AssumeRolePolicyDocument: - Version: "2012-10-17" - Statement: - - Effect: Allow - Principal: - Service: lambda.amazonaws.com - Action: sts:AssumeRole - Path: /lambda/ - - LambdaPolicy: - Type: AWS::IAM::Policy - Properties: - PolicyName: !Sub "${AWS::StackName}-LambdaPolicy" - Roles: - - Ref: LambdaExecutionRole - PolicyDocument: - Version: "2012-10-17" - Statement: - # CloudWatch Logs - scoped to this Lambda's log group - - Sid: CloudWatchLogs - Effect: Allow - Action: - - logs:CreateLogGroup - - logs:CreateLogStream - - logs:PutLogEvents - Resource: - - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${AWS::StackName}-*" - - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${AWS::StackName}-*:*" - - # Cost Explorer for recommendations - - Sid: CostExplorer - Effect: Allow - Action: - - ce:GetCostAndUsage - - ce:GetReservationPurchaseRecommendation - - ce:GetReservationUtilization - - ce:GetReservationCoverage - - ce:GetSavingsPlansPurchaseRecommendation - - ce:GetSavingsPlansUtilization - - ce:GetSavingsPlansCoverage - Resource: "*" - - # RDS Reserved Instances - - Sid: RDSReservedInstances - Effect: Allow - Action: - - rds:DescribeReservedDBInstancesOfferings - - rds:DescribeReservedDBInstances - - rds:PurchaseReservedDBInstancesOffering - - rds:DescribeDBInstances - Resource: "*" - - # ElastiCache Reserved Nodes - - Sid: ElastiCacheReservedNodes - Effect: Allow - Action: - - elasticache:DescribeReservedCacheNodesOfferings - - elasticache:DescribeReservedCacheNodes - - elasticache:PurchaseReservedCacheNodesOffering - Resource: "*" - - # EC2 Reserved Instances - - Sid: EC2ReservedInstances - Effect: Allow - Action: - - ec2:DescribeReservedInstancesOfferings - - ec2:DescribeReservedInstances - - ec2:PurchaseReservedInstancesOffering - - ec2:GetReservedInstancesExchangeQuote - - ec2:AcceptReservedInstancesExchangeQuote - - ec2:DescribeRegions - - ec2:DescribeInstanceTypeOfferings - - ec2:DescribeInstanceTypes - - ec2:CreateReservedInstancesListing - - ec2:DescribeReservedInstancesListings - - ec2:CancelReservedInstancesListing - Resource: "*" - - # Post-purchase tagging of EC2 RIs; the only action here that - # supports resource-level scoping. - - Sid: EC2ReservedInstanceTagging - Effect: Allow - Action: - - ec2:CreateTags - Resource: "arn:aws:ec2:*:*:reserved-instances/*" - - # OpenSearch Reserved Instances - - Sid: OpenSearchReservedInstances - Effect: Allow - Action: - - es:DescribeReservedInstanceOfferings - - es:DescribeReservedInstances - - es:PurchaseReservedInstanceOffering - Resource: "*" - - # Redshift Reserved Nodes - - Sid: RedshiftReservedNodes - Effect: Allow - Action: - - redshift:DescribeReservedNodeOfferings - - redshift:DescribeReservedNodes - - redshift:PurchaseReservedNodeOffering - Resource: "*" - - # MemoryDB Reserved Nodes - - Sid: MemoryDBReservedNodes - Effect: Allow - Action: - - memorydb:DescribeReservedNodesOfferings - - memorydb:DescribeReservedNodes - - memorydb:PurchaseReservedNodesOffering - Resource: "*" - - # Savings Plans - - Sid: SavingsPlans - Effect: Allow - Action: - - savingsplans:DescribeSavingsPlans - - savingsplans:CreateSavingsPlan - - savingsplans:DescribeSavingsPlansOfferings - - savingsplans:DescribeSavingsPlansOfferingRates - Resource: "*" - - # Account discovery - - Sid: AccountDiscovery - Effect: Allow - Action: - - sts:GetCallerIdentity - - organizations:ListAccounts - Resource: "*" - - # Cross-account role assumption for multi-account plans. - # - # aws:ResourceAccount pins the grant to the declared accounts. The - # Resource pattern narrows which role but never whose account: - # arn:aws:iam::*:role/CUDly* matches a CUDly role in any AWS account - # on earth, so before #1636 a mis-selected account produced a - # successful AssumeRole instead of an AccessDenied. sts:ExternalId - # StringLike "*" requires the field to be present and non-empty, at - # parity with the Terraform modules; per-account values are checked - # in internal/credentials/resolver.go. - # - # Dropped entirely rather than widened when no accounts are declared. - - Fn::If: - - HasCrossAccountTargets - - Sid: CrossAccountAssumeRole - Effect: Allow - Action: - - sts:AssumeRole - Resource: "arn:aws:iam::*:role/CUDly*" - Condition: - StringEquals: - aws:ResourceAccount: - Ref: CrossAccountTargetAccountIds - StringLike: - sts:ExternalId: "*" - - Ref: AWS::NoValue - - # DynamoDB access - - Sid: DynamoDBAccess - Effect: Allow - Action: - - dynamodb:GetItem - - dynamodb:PutItem - - dynamodb:DeleteItem - - dynamodb:Query - - dynamodb:Scan - - dynamodb:UpdateItem - - dynamodb:BatchWriteItem - - dynamodb:BatchGetItem - Resource: - - !GetAtt ConfigTable.Arn - - !GetAtt PurchasePlansTable.Arn - - !GetAtt PurchaseHistoryTable.Arn - - !GetAtt UsersTable.Arn - - !Sub "${UsersTable.Arn}/index/*" - - !GetAtt GroupsTable.Arn - - !GetAtt SessionsTable.Arn - - !Sub "${SessionsTable.Arn}/index/*" - - # Secrets Manager - - Sid: SecretsManager - Effect: Allow - Action: - - secretsmanager:GetSecretValue - Resource: - - !Ref APIKeySecret - - # SNS for email notifications - - Sid: SNSPublish - Effect: Allow - Action: - - sns:Publish - Resource: - - !Ref NotificationTopic - - # SES for email (if used) - - Sid: SESEmail - Effect: Allow - Action: - - ses:SendEmail - - ses:SendTemplatedEmail - Resource: "*" - - # ============================================================================= - # Lambda Function - # ============================================================================= - - LambdaFunction: - Type: AWS::Lambda::Function - Properties: - FunctionName: !Sub "${AWS::StackName}-Handler" - PackageType: Image - Architectures: - - !If [Arm64, arm64, x86_64] - Code: - ImageUri: !Ref LambdaImageUri - Description: CUDly - Multi-cloud commitment and discount manager - Environment: - Variables: - # Configuration - CONFIG_TABLE: !Ref ConfigTable - PLANS_TABLE: !Ref PurchasePlansTable - HISTORY_TABLE: !Ref PurchaseHistoryTable - USERS_TABLE: !Ref UsersTable - GROUPS_TABLE: !Ref GroupsTable - SESSIONS_TABLE: !Ref SessionsTable - API_KEY_SECRET_ARN: !Ref APIKeySecret - NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic - # Defaults - DEFAULT_TERM: !Ref DefaultTerm - DEFAULT_PAYMENT_OPTION: !Ref DefaultPaymentOption - DEFAULT_COVERAGE: !Ref DefaultCoverage - DEFAULT_RAMP_SCHEDULE: !Ref DefaultRampSchedule - NOTIFICATION_DAYS_BEFORE: !Ref NotificationDaysBeforePurchase - EMAIL_ADDRESS: !Ref EmailAddress - # Dashboard - ENABLE_DASHBOARD: !Ref EnableDashboard - DASHBOARD_BUCKET: !If [DeployDashboard, !Ref DashboardBucket, ""] - # CORS and Dashboard URL - # Note: When using a custom domain, CORS is restricted to that domain. - # Without a custom domain, CORS defaults to "*" (set at runtime). - # For production, always configure a custom domain for security. - CORS_ALLOWED_ORIGIN: !If [HasCustomDomain, !Sub "https://${DashboardDomainName}", ""] - DASHBOARD_URL: !If [HasCustomDomain, !Sub "https://${DashboardDomainName}", ""] - MemorySize: !Ref LambdaMemorySize - Role: !GetAtt LambdaExecutionRole.Arn - Timeout: 900 - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-Lambda" - - LambdaLogGroup: - Type: AWS::Logs::LogGroup - DeletionPolicy: Retain - UpdateReplacePolicy: Retain - Properties: - LogGroupName: !Sub "/aws/lambda/${LambdaFunction}" - RetentionInDays: !Ref LogRetentionPeriod - - # ============================================================================= - # Lambda Function URL (for API) - # ============================================================================= - - LambdaFunctionUrl: - Type: AWS::Lambda::Url - Condition: DeployDashboard - Properties: - AuthType: AWS_IAM - TargetFunctionArn: !GetAtt LambdaFunction.Arn - Cors: - # When a custom domain is configured, restrict CORS to that origin. - # Without a custom domain the wildcard is unavoidable for testing, - # but production deployments should always set DashboardDomainName. - # AllowMethods and AllowHeaders match the Terraform lambda module - # (terraform/modules/compute/aws/lambda/main.tf cors block). - AllowOrigins: - - !If [HasCustomDomain, !Sub "https://${DashboardDomainName}", "*"] - AllowMethods: - - GET - - POST - - PUT - - PATCH - - DELETE - AllowHeaders: - - Content-Type - - Authorization - - X-CSRF-Token - - X-Session-Token - - # Permission for CloudFront OAC to invoke Lambda - must be created AFTER the distribution - LambdaFunctionUrlPermission: - Type: AWS::Lambda::Permission - Condition: DeployDashboard - DependsOn: DashboardDistribution - Properties: - Action: lambda:InvokeFunctionUrl - FunctionName: !Ref LambdaFunction - Principal: cloudfront.amazonaws.com - SourceArn: !Sub "arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${DashboardDistribution}" - FunctionUrlAuthType: AWS_IAM - - # ============================================================================= - # Scheduled Execution - # ============================================================================= - - ScheduledRule: - Type: AWS::Events::Rule - Properties: - Name: !Sub "${AWS::StackName}-ScheduledExecution" - Description: Scheduled execution for CUDly recommendation collection - ScheduleExpression: !Ref ExecutionFrequency - State: ENABLED - Targets: - - Id: LambdaTarget - Arn: !GetAtt LambdaFunction.Arn - Input: '{"action": "collect_recommendations"}' - - ScheduledRulePermission: - Type: AWS::Lambda::Permission - Properties: - Action: lambda:InvokeFunction - FunctionName: !Ref LambdaFunction - Principal: events.amazonaws.com - SourceArn: !GetAtt ScheduledRule.Arn - - # Daily check for scheduled purchases - PurchaseCheckRule: - Type: AWS::Events::Rule - Properties: - Name: !Sub "${AWS::StackName}-PurchaseCheck" - Description: Daily check for scheduled automated purchases - ScheduleExpression: "rate(1 day)" - State: ENABLED - Targets: - - Id: LambdaTarget - Arn: !GetAtt LambdaFunction.Arn - Input: '{"action": "process_scheduled_purchases"}' - - PurchaseCheckPermission: - Type: AWS::Lambda::Permission - Properties: - Action: lambda:InvokeFunction - FunctionName: !Ref LambdaFunction - Principal: events.amazonaws.com - SourceArn: !GetAtt PurchaseCheckRule.Arn - - # Remaining scheduled server tasks (#712), one rule and invoke permission - # each, switched by the Enable*Schedule parameters. Name suffixes are at most - # as long as "-ScheduledExecution", so they fit EventBridge's 64-character - # limit for any stack name the existing rules accept. Retries stay at the - # EventBridge default: a re-run is safe under the server's per-task advisory - # lock and row compare-and-swap. - FinalizeRevocationsRule: - Type: AWS::Events::Rule - Condition: ScheduleFinalizeRevocations - Properties: - Name: !Sub "${AWS::StackName}-Finalize" - Description: Scheduled finalize_revocations task - ScheduleExpression: !Ref FinalizeRevocationsSchedule - State: ENABLED - Targets: - - Id: LambdaTarget - Arn: !GetAtt LambdaFunction.Arn - Input: '{"action": "finalize_revocations"}' - - FinalizeRevocationsPermission: - Type: AWS::Lambda::Permission - Condition: ScheduleFinalizeRevocations - Properties: - Action: lambda:InvokeFunction - FunctionName: !Ref LambdaFunction - Principal: events.amazonaws.com - SourceArn: !GetAtt FinalizeRevocationsRule.Arn - - AnalyticsCollectRule: - Type: AWS::Events::Rule - Condition: ScheduleAnalyticsCollect - Properties: - Name: !Sub "${AWS::StackName}-AnalyticsCollect" - Description: Scheduled analytics_collect task - ScheduleExpression: !Ref AnalyticsCollectSchedule - State: ENABLED - Targets: - - Id: LambdaTarget - Arn: !GetAtt LambdaFunction.Arn - Input: '{"action": "analytics_collect"}' - - AnalyticsCollectPermission: - Type: AWS::Lambda::Permission - Condition: ScheduleAnalyticsCollect - Properties: - Action: lambda:InvokeFunction - FunctionName: !Ref LambdaFunction - Principal: events.amazonaws.com - SourceArn: !GetAtt AnalyticsCollectRule.Arn - - ReapStuckPurchasesRule: - Type: AWS::Events::Rule - Condition: ScheduleReapStuckPurchases - Properties: - Name: !Sub "${AWS::StackName}-ReapStuck" - Description: Scheduled reap_stuck_purchases task - ScheduleExpression: !Ref ReapStuckPurchasesSchedule - State: ENABLED - Targets: - - Id: LambdaTarget - Arn: !GetAtt LambdaFunction.Arn - Input: '{"action": "reap_stuck_purchases"}' - - ReapStuckPurchasesPermission: - Type: AWS::Lambda::Permission - Condition: ScheduleReapStuckPurchases - Properties: - Action: lambda:InvokeFunction - FunctionName: !Ref LambdaFunction - Principal: events.amazonaws.com - SourceArn: !GetAtt ReapStuckPurchasesRule.Arn - - FireScheduledPurchasesRule: - Type: AWS::Events::Rule - Condition: ScheduleFireScheduledPurchases - Properties: - Name: !Sub "${AWS::StackName}-FireScheduled" - Description: Scheduled fire_scheduled_purchases task - ScheduleExpression: !Ref FireScheduledPurchasesSchedule - State: ENABLED - Targets: - - Id: LambdaTarget - Arn: !GetAtt LambdaFunction.Arn - Input: '{"action": "fire_scheduled_purchases"}' - - FireScheduledPurchasesPermission: - Type: AWS::Lambda::Permission - Condition: ScheduleFireScheduledPurchases - Properties: - Action: lambda:InvokeFunction - FunctionName: !Ref LambdaFunction - Principal: events.amazonaws.com - SourceArn: !GetAtt FireScheduledPurchasesRule.Arn - - SendNotificationsRule: - Type: AWS::Events::Rule - Condition: ScheduleSendNotifications - Properties: - Name: !Sub "${AWS::StackName}-SendNotifications" - Description: Scheduled send_notifications task - ScheduleExpression: !Ref SendNotificationsSchedule - State: ENABLED - Targets: - - Id: LambdaTarget - Arn: !GetAtt LambdaFunction.Arn - Input: '{"action": "send_notifications"}' - - SendNotificationsPermission: - Type: AWS::Lambda::Permission - Condition: ScheduleSendNotifications - Properties: - Action: lambda:InvokeFunction - FunctionName: !Ref LambdaFunction - Principal: events.amazonaws.com - SourceArn: !GetAtt SendNotificationsRule.Arn - - CleanupRule: - Type: AWS::Events::Rule - Condition: ScheduleCleanup - Properties: - Name: !Sub "${AWS::StackName}-Cleanup" - Description: Scheduled cleanup task - ScheduleExpression: !Ref CleanupSchedule - State: ENABLED - Targets: - - Id: LambdaTarget - Arn: !GetAtt LambdaFunction.Arn - Input: '{"action": "cleanup"}' - - CleanupPermission: - Type: AWS::Lambda::Permission - Condition: ScheduleCleanup - Properties: - Action: lambda:InvokeFunction - FunctionName: !Ref LambdaFunction - Principal: events.amazonaws.com - SourceArn: !GetAtt CleanupRule.Arn - - # ============================================================================= - # SNS Topic for Notifications - # ============================================================================= - - NotificationTopic: - Type: AWS::SNS::Topic - Properties: - TopicName: !Sub "${AWS::StackName}-Notifications" - KmsMasterKeyId: alias/aws/sns - Subscription: - - Endpoint: !Ref EmailAddress - Protocol: email - - # ============================================================================= - # S3 Bucket for Dashboard - # ============================================================================= - - DashboardBucket: - Type: AWS::S3::Bucket - Condition: DeployDashboard - DeletionPolicy: Delete - Properties: - PublicAccessBlockConfiguration: - BlockPublicAcls: true - BlockPublicPolicy: true - IgnorePublicAcls: true - RestrictPublicBuckets: true - BucketEncryption: - ServerSideEncryptionConfiguration: - - ServerSideEncryptionByDefault: - SSEAlgorithm: AES256 - VersioningConfiguration: - Status: Enabled - - DashboardBucketPolicy: - Type: AWS::S3::BucketPolicy - Condition: DeployDashboard - Properties: - Bucket: !Ref DashboardBucket - PolicyDocument: - Version: "2012-10-17" - Statement: - - Sid: AllowCloudFrontOAC - Effect: Allow - Principal: - Service: cloudfront.amazonaws.com - Action: s3:GetObject - Resource: !Sub "${DashboardBucket.Arn}/*" - Condition: - StringEquals: - "AWS:SourceArn": !Sub "arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${DashboardDistribution}" - - # ============================================================================= - # CloudFront Distribution - # ============================================================================= - - DashboardOAC: - Type: AWS::CloudFront::OriginAccessControl - Condition: DeployDashboard - Properties: - OriginAccessControlConfig: - Name: !Sub "${AWS::StackName}-dashboard-oac" - Description: OAC for CUDly Dashboard S3 bucket - OriginAccessControlOriginType: s3 - SigningBehavior: always - SigningProtocol: sigv4 - - # OAC for Lambda Function URL - ensures only CloudFront can invoke Lambda - LambdaOAC: - Type: AWS::CloudFront::OriginAccessControl - Condition: DeployDashboard - Properties: - OriginAccessControlConfig: - Name: !Sub "${AWS::StackName}-lambda-oac" - Description: OAC for CUDly Lambda Function URL - OriginAccessControlOriginType: lambda - SigningBehavior: always - SigningProtocol: sigv4 - - DirectoryIndexFunction: - Type: AWS::CloudFront::Function - Condition: DeployDashboard - Properties: - Name: !Sub "${AWS::StackName}-directory-index" - AutoPublish: true - FunctionConfig: - Comment: Rewrite directory requests to include index.html - Runtime: cloudfront-js-2.0 - FunctionCode: | - function handler(event) { - var request = event.request; - var uri = request.uri; - - // Check if URI ends with / or has no extension (directory request) - if (uri.endsWith('/')) { - request.uri = uri + 'index.html'; - } else if (!uri.includes('.') && !uri.startsWith('/api')) { - // No extension and not an API request - treat as directory - request.uri = uri + '/index.html'; - } - - return request; - } - - DashboardDistribution: - Type: AWS::CloudFront::Distribution - Condition: DeployDashboard - Properties: - DistributionConfig: - Enabled: true - DefaultRootObject: index.html - Aliases: - !If - - HasCustomDomain - - - !Ref DashboardDomainName - - !Ref "AWS::NoValue" - ViewerCertificate: - !If - - HasCustomDomain - - AcmCertificateArn: !Ref DashboardCertificate - SslSupportMethod: sni-only - MinimumProtocolVersion: TLSv1.2_2021 - - CloudFrontDefaultCertificate: true - Origins: - - Id: S3Origin - DomainName: !GetAtt DashboardBucket.RegionalDomainName - S3OriginConfig: - OriginAccessIdentity: "" - OriginAccessControlId: !Ref DashboardOAC - - Id: APIOrigin - DomainName: !Select [2, !Split ["/", !GetAtt LambdaFunctionUrl.FunctionUrl]] - OriginAccessControlId: !Ref LambdaOAC - CustomOriginConfig: - HTTPSPort: 443 - OriginProtocolPolicy: https-only - DefaultCacheBehavior: - TargetOriginId: S3Origin - ViewerProtocolPolicy: redirect-to-https - CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # CachingOptimized - AllowedMethods: - - GET - - HEAD - CachedMethods: - - GET - - HEAD - FunctionAssociations: - - EventType: viewer-request - FunctionARN: !GetAtt DirectoryIndexFunction.FunctionARN - CacheBehaviors: - - PathPattern: "/api/*" - TargetOriginId: APIOrigin - ViewerProtocolPolicy: redirect-to-https - CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad # CachingDisabled - OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac # AllViewerExceptHostHeader - AllowedMethods: - - GET - - HEAD - - OPTIONS - - PUT - - POST - - PATCH - - DELETE - - PathPattern: "/docs*" - TargetOriginId: APIOrigin - ViewerProtocolPolicy: redirect-to-https - CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # CachingOptimized (docs can be cached) - OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac # AllViewerExceptHostHeader - AllowedMethods: - - GET - - HEAD - - OPTIONS - PriceClass: PriceClass_100 - CustomErrorResponses: - - ErrorCode: 403 - ResponseCode: 200 - ResponsePagePath: /index.html - - ErrorCode: 404 - ResponseCode: 200 - ResponsePagePath: /index.html - - # ============================================================================= - # Custom Domain Resources (Optional) - # ============================================================================= - - DashboardCertificate: - Type: AWS::CertificateManager::Certificate - Condition: HasCustomDomain - Properties: - DomainName: !Ref DashboardDomainName - ValidationMethod: DNS - DomainValidationOptions: - - DomainName: !Ref DashboardDomainName - HostedZoneId: !Ref DashboardHostedZoneId - - DashboardDNSRecord: - Type: AWS::Route53::RecordSet - Condition: HasCustomDomain - Properties: - HostedZoneId: !Ref DashboardHostedZoneId - Name: !Ref DashboardDomainName - Type: A - AliasTarget: - DNSName: !GetAtt DashboardDistribution.DomainName - HostedZoneId: Z2FDTNDATAQYW2 # CloudFront hosted zone ID - EvaluateTargetHealth: false - -Outputs: - LambdaFunctionArn: - Description: ARN of the CUDly Lambda function - Value: !GetAtt LambdaFunction.Arn - Export: - Name: !Sub "${AWS::StackName}-LambdaArn" - - ConfigTableName: - Description: Name of the configuration DynamoDB table - Value: !Ref ConfigTable - Export: - Name: !Sub "${AWS::StackName}-ConfigTable" - - PurchasePlansTableName: - Description: Name of the purchase plans DynamoDB table - Value: !Ref PurchasePlansTable - Export: - Name: !Sub "${AWS::StackName}-PlansTable" - - PurchaseHistoryTableName: - Description: Name of the purchase history DynamoDB table - Value: !Ref PurchaseHistoryTable - Export: - Name: !Sub "${AWS::StackName}-HistoryTable" - - APIKeySecretArn: - Description: ARN of the API Key secret - Value: !Ref APIKeySecret - Export: - Name: !Sub "${AWS::StackName}-APIKeySecret" - - NotificationTopicArn: - Description: ARN of the SNS notification topic - Value: !Ref NotificationTopic - Export: - Name: !Sub "${AWS::StackName}-NotificationTopic" - - DashboardURL: - Condition: DeployDashboard - Description: URL of the CUDly Dashboard - Value: - !If - - HasCustomDomain - - !Sub "https://${DashboardDomainName}" - - !Sub "https://${DashboardDistribution.DomainName}" - Export: - Name: !Sub "${AWS::StackName}-DashboardURL" - - DashboardBucketName: - Condition: DeployDashboard - Description: Name of the S3 bucket for dashboard static files - Value: !Ref DashboardBucket - Export: - Name: !Sub "${AWS::StackName}-DashboardBucket" - - LambdaFunctionURL: - Condition: DeployDashboard - Description: Lambda Function URL for API access - Value: !GetAtt LambdaFunctionUrl.FunctionUrl - Export: - Name: !Sub "${AWS::StackName}-LambdaFunctionURL" - - UsersTableName: - Description: Name of the users DynamoDB table - Value: !Ref UsersTable - Export: - Name: !Sub "${AWS::StackName}-UsersTable" - - GroupsTableName: - Description: Name of the groups DynamoDB table - Value: !Ref GroupsTable - Export: - Name: !Sub "${AWS::StackName}-GroupsTable" - - SessionsTableName: - Description: Name of the sessions DynamoDB table - Value: !Ref SessionsTable - Export: - Name: !Sub "${AWS::StackName}-SessionsTable" diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index af54acca3..340184056 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -2,6 +2,8 @@ CUDly supports deployment via Terraform across three cloud providers (AWS, Azure, GCP). A helper script (`scripts/tf-deploy.sh`) simplifies common operations. +There is no CloudFormation install. The CloudFormation install stack was removed (#1432): it still provisioned DynamoDB tables and set no `DB_HOST`, so it could not start the Postgres-only server. Install on AWS with the Terraform root in `terraform/environments/aws` (Lambda or Fargate). The CloudFormation templates under `iac/federation/` and `cloudformation/stacks/CUDly-CrossAccount` are not installs: they create the IAM role CUDly uses in each target account. + ## Table of Contents - [Quick Start](#quick-start) @@ -599,11 +601,10 @@ 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, AWS Fargate, CloudFormation, GCP Cloud Run, GKE and AKS 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, GCP Cloud Run, GKE and AKS 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 (opt-in):** the same `enable_cleanup_schedule` / `cleanup_schedule` variables in `terraform/environments/aws` add a `-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 Cloud Run and GKE (opt-in):** set `enable_cleanup_schedule = true` (default false; `cleanup_schedule` defaults to `0 3 * * *`) in `terraform/environments/gcp`. It runs as a Cloud Scheduler job (`scheduled-tasks.tf` in each module) only when the module's `enable_scheduled_tasks` is true; that environment passes it to Cloud Run but not to GKE. The same backlog warning applies. 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. @@ -615,7 +616,6 @@ Alternatively, use pg_cron for database-native scheduling. CUDly learns that an RI listed on the AWS Reserved Instance Marketplace sold, closed or was canceled on AWS, and records its estimated proceeds and AWS fee, only through the marketplace listing poll. The poll has no schedule of its own: it runs inside the server's `finalize_revocations` task, after the revocation repair. It therefore runs only where `finalize_revocations` is scheduled, and turning that schedule off also stops the poll: - **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. - **GCP Cloud Run, GKE and Azure Container Apps:** hourly by default, as a Cloud Scheduler job (GCP) or a Logic App (Container Apps), when the module's `enable_scheduled_tasks` is true. `enable_finalize_revocations_schedule` (default `true`) and `finalize_revocations_schedule` (default `30 * * * *`) in `terraform/environments/gcp` and `terraform/environments/azure`. `terraform/environments/gcp` does not pass `enable_scheduled_tasks` or `app_url` to GKE, so a GKE deployment from there has no Cloud Scheduler jobs. @@ -661,9 +661,8 @@ cross-account until you do. | Deployment shape | Setting | | ---------------- | ------- | | Terraform (`terraform/environments/aws`) | `cross_account_target_account_ids = ["111111111111", ...]` in your tfvars | -| CloudFormation (`cloudformation/stacks/CUDly`) | `CrossAccountTargetAccountIds` stack parameter, comma-separated | -Both render an `aws:ResourceAccount` condition onto the grant. An account that is not listed is +It renders an `aws:ResourceAccount` condition onto the grant. An account that is not listed is denied by IAM, not merely by the app's account selection. Leaving the setting empty creates **no cross-account grant at all**, which is the intended fail-closed default rather than an oversight. @@ -673,10 +672,9 @@ own identity policy, which this setting does not govern. > **Upgrading an existing multi-account deployment**: the grant used to be scoped by role name only > (`arn:aws:iam::*:role/CUDly*`), which matched a CUDly role in *every* AWS account rather than in -> yours (#1636). List your linked accounts **in the same change that picks up this version**. On -> Terraform the apply removes `aws_iam_role_policy.cross_account_sts` and exits 0; on CloudFormation -> the stack update drops the statement. Either way the first symptom otherwise is a runtime -> `AccessDenied` during collection, not a failed deploy. +> yours (#1636). List your linked accounts **in the same change that picks up this version**. The +> Terraform apply removes `aws_iam_role_policy.cross_account_sts` and exits 0, so the first symptom +> otherwise is a runtime `AccessDenied` during collection, not a failed deploy. ### Multi-Account Credential Encryption diff --git a/internal/testutil/lambdatimeout/lambdatimeout.go b/internal/testutil/lambdatimeout/lambdatimeout.go index 0436eba3f..5c1ce2b6c 100644 --- a/internal/testutil/lambdatimeout/lambdatimeout.go +++ b/internal/testutil/lambdatimeout/lambdatimeout.go @@ -26,16 +26,15 @@ var ( tfModuleFloor = regexp.MustCompile(`var\.timeout\s*>=\s*(\d+)`) tfRootFloor = regexp.MustCompile(`var\.lambda_timeout\s*>=\s*(\d+)`) tfvarsLambdaTimeout = regexp.MustCompile(`(?m)^\s*lambda_timeout\s*=\s*(\d+)`) - cfnLambdaTimeout = regexp.MustCompile(`(?s)LambdaFunction:\s*\n\s*Type: AWS::Lambda::Function\b.*?\n\s{6}Timeout:\s*(\d+)`) ) // AssertCovers fails t unless every AWS Lambda timeout the // repository ships covers budget plus Margin: the Terraform // module and root variable defaults and their validation floors, every // committed lambda_timeout tfvars value (including examples and the -// deployment guide), and the CloudFormation function's Timeout. Lambda ends an -// invocation at its timeout whatever a context deadline says, so detached -// work with a longer budget would be cut off mid-flight (#913). +// deployment guide). Lambda ends an invocation at its timeout whatever a +// context deadline says, so detached work with a longer budget would be cut +// off mid-flight (#913). // // repoRoot is the repository root relative to the calling test's package. func AssertCovers(t *testing.T, repoRoot string, budget time.Duration) { @@ -43,8 +42,6 @@ func AssertCovers(t *testing.T, repoRoot string, budget time.Duration) { c := &checker{t: t, repoRoot: repoRoot, budget: budget, need: budget + Margin} c.terraformVariables() c.tfvarsAndDocs() - c.one("cloudformation/stacks/CUDly/template.yaml LambdaFunction Timeout", cfnLambdaTimeout, - c.read("cloudformation/stacks/CUDly/template.yaml")) } // checker holds the state shared by the AssertCovers checks. diff --git a/known_issues/33_executions_retention_cleanup.md b/known_issues/33_executions_retention_cleanup.md index e8addcb9b..b79dcf4a1 100644 --- a/known_issues/33_executions_retention_cleanup.md +++ b/known_issues/33_executions_retention_cleanup.md @@ -2,14 +2,14 @@ **Surfaced during:** 2026-04-23 follow-up audit — Failed + Expired rows now persist and surface in History **Related commits:** `32f9e4ffc` (pending-in-history), `b44283746` (failed + expired states) -**Status:** Partially resolved. The cleanup task exists (verified 2026-04-25) but retention is unscheduled by default on AWS, GCP and CFN (opt-in there); Azure Container Apps schedules it. +**Status:** Partially resolved. The cleanup task exists (verified 2026-04-25) but retention is unscheduled by default on AWS and GCP (opt-in there); Azure Container Apps schedules it. > **Amendment (#712):** the AWS trigger claimed below (`cleanup-lambda/main.tf:98`) > is false: no environment consumes the `cleanup-lambda` module, so nothing > 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, AWS Fargate, CloudFormation, GCP Cloud Run, GKE and AKS when opted in. +> scheduling is tracked by #712; retention is scheduled via the server `cleanup` task on AWS Lambda, AWS Fargate, GCP Cloud Run, GKE and AKS when opted in. ## Resolution @@ -24,9 +24,10 @@ - **AWS Fargate (opt-in):** the same `enable_cleanup_schedule` / `cleanup_schedule` variables add a `-fargate-sweep-cleanup` rule that runs `./cudly --task cleanup` as a one-off ECS task. -- **CloudFormation (opt-in):** the `EnableCleanupSchedule` / +- ~~**CloudFormation (opt-in):** the `EnableCleanupSchedule` / `CleanupSchedule` stack parameters add a `-Cleanup` rule that runs - the server cleanup task. + the server cleanup task.~~ (the CloudFormation install stack was removed in + #1432; use the Terraform AWS install) - **Azure Container Apps:** a Logic App calls `/api/scheduled/cleanup` daily when `enable_scheduled_tasks` is true. (The `azure/cleanup-function` module is not consumed by any environment.) @@ -41,7 +42,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, AWS Fargate, CloudFormation, GCP and AKS when opted in, see amendment). +only where that task is scheduled (Azure Container Apps by default; AWS Lambda, AWS Fargate, GCP and AKS when opted in, see amendment). ## Original problem (historical context) diff --git a/scripts/check-aws-iam-parity.sh b/scripts/check-aws-iam-parity.sh index 8983ee7a2..0f4905b06 100755 --- a/scripts/check-aws-iam-parity.sh +++ b/scripts/check-aws-iam-parity.sh @@ -3,14 +3,15 @@ # # Asserts that the AWS IAM action lists granted to the CUDly runtime and to the # customer-deployed federation roles are identical across every IaC flavor that -# encodes them, so CloudFormation and Terraform deployments of the same code +# encodes them, so deployments of the same code through different flavors # cannot silently drift apart (the AWS sibling of check-azure-role-parity.sh). # # Three comparisons: # -# 1. runtime : cloudformation/stacks/CUDly/template.yaml -# == terraform/modules/compute/aws/lambda/main.tf +# 1. runtime : terraform/modules/compute/aws/lambda/main.tf # == terraform/modules/compute/aws/fargate/main.tf +# (the CloudFormation install stack that used to be a third runtime copy +# was retired in #1432) # 2. federation cross-account pair (incl. the optional org-discovery # statement, which exists in both as an opt-in): # iac/federation/aws-cross-account/cloudformation/template.yaml @@ -202,17 +203,14 @@ compare_pair() { fi } -# --- 1. runtime: CFN stack vs TF lambda vs TF fargate -------------------------- +# --- 1. runtime: TF lambda vs TF fargate --------------------------------------- -CFN_STACK="$REPO_ROOT/cloudformation/stacks/CUDly/template.yaml" TF_LAMBDA="$REPO_ROOT/terraform/modules/compute/aws/lambda/main.tf" TF_FARGATE="$REPO_ROOT/terraform/modules/compute/aws/fargate/main.tf" -cfn_stack_actions=$(extract_actions "$CFN_STACK") tf_lambda_actions=$(extract_actions "$TF_LAMBDA") tf_fargate_actions=$(extract_actions "$TF_FARGATE") -compare_pair "runtime" "$CFN_STACK" "$cfn_stack_actions" "$TF_LAMBDA" "$tf_lambda_actions" compare_pair "runtime" "$TF_LAMBDA" "$tf_lambda_actions" "$TF_FARGATE" "$tf_fargate_actions" # --- 2. federation cross-account pair (all namespaces) ------------------------- @@ -253,7 +251,7 @@ REQUIRED_CUSTOMER_ACTIONS=( for f in "$FED_XACC_CFN" "$FED_XACC_TF" "$FED_WIF_CFN" "$FED_WIF_TF" "$FED_XACC_CLI" "$FED_WIF_CLI" \ "$REPO_ROOT/cloudformation/stacks/CUDly-CrossAccount/template.yaml" \ - "$CFN_STACK" "$TF_LAMBDA" "$TF_FARGATE"; do + "$TF_LAMBDA" "$TF_FARGATE"; do granted=$(extract_actions "$f") for a in "${REQUIRED_CUSTOMER_ACTIONS[@]}"; do if ! grep -qxF "$a" <<<"$granted"; then @@ -264,15 +262,15 @@ for f in "$FED_XACC_CFN" "$FED_XACC_TF" "$FED_WIF_CFN" "$FED_WIF_TF" "$FED_XACC_ done # --- 5. runtime organizations grants ------------------------------------------- -# Parity only proves the runtime trio agree, so an unused organizations action -# added to all three (#69: DescribeAccount, DescribeOrganization) passes it. +# Parity only proves the runtime copies agree, so an unused organizations action +# added to all of them (#69: DescribeAccount, DescribeOrganization) passes it. # The runtime calls only ListAccounts (internal/accounts/org_discovery.go and # the cloud-commitments-go AWS provider), so that is the only organizations # action it may grant. Wire up a new call before adding its action here. ALLOWED_RUNTIME_ORG_ACTIONS=(organizations:ListAccounts) -for f in "$CFN_STACK" "$TF_LAMBDA" "$TF_FARGATE"; do +for f in "$TF_LAMBDA" "$TF_FARGATE"; do while IFS= read -r a; do if [[ " ${ALLOWED_RUNTIME_ORG_ACTIONS[*]} " != *" $a "* ]]; then echo "ERROR [runtime organizations actions]: $f grants $a, which the runtime never calls" >&2 diff --git a/scripts/test-aws-iam-parity.sh b/scripts/test-aws-iam-parity.sh index 31d409c34..5cf484564 100755 --- a/scripts/test-aws-iam-parity.sh +++ b/scripts/test-aws-iam-parity.sh @@ -36,12 +36,11 @@ run_case() { fi } -# seed_fixture copies the six compared files from the repo into a +# seed_fixture copies the compared files from the repo into a # fixture tree that mirrors the repo layout. seed_fixture() { local dest="$1" local files=( - cloudformation/stacks/CUDly/template.yaml terraform/modules/compute/aws/lambda/main.tf terraform/modules/compute/aws/fargate/main.tf iac/federation/aws-cross-account/cloudformation/template.yaml @@ -66,7 +65,7 @@ trap 'rm -rf "$TMP_BASE"' EXIT run_case "real repository is in parity" 0 # Case 2: pre-fix INF-02 shape - TF Lambda module missing the CE Coverage -# actions while the CFN stack grants them. +# actions while the TF Fargate module grants them. FIX2="$TMP_BASE/drift-runtime" seed_fixture "$FIX2" grep -v 'ce:GetReservationCoverage\|ce:GetSavingsPlansCoverage' \ @@ -138,10 +137,10 @@ sed 's/^\( *\)- ec2:DescribeInstanceTypes$/\1# - ec2:DescribeInstanceTypes/' \ > "$FIX10/cloudformation/stacks/CUDly-CrossAccount/template.yaml" run_case "commented-out required action exits 1" 1 --root "$FIX10" -# Case 11: a required read missing from all three runtime copies (parity holds) must fail. +# Case 11: a required read missing from both runtime copies (parity holds) must fail. FIX11="$TMP_BASE/drift-required-runtime" seed_fixture "$FIX11" -for f in cloudformation/stacks/CUDly/template.yaml terraform/modules/compute/aws/lambda/main.tf terraform/modules/compute/aws/fargate/main.tf; do +for f in terraform/modules/compute/aws/lambda/main.tf terraform/modules/compute/aws/fargate/main.tf; do sed 's/ce:GetCostAndUsage//' "$REPO_ROOT/$f" > "$FIX11/$f" done run_case "required action missing from all runtime copies exits 1" 1 --root "$FIX11" @@ -176,32 +175,29 @@ cp "$REPO_ROOT/terraform/modules/compute/aws/lambda/main.tf" "$FIX15/terraform/m printf '%s\n' ' "Resource": "*",' >> "$FIX15/terraform/modules/compute/aws/lambda/main.tf" run_case "Resource \"*\" is not treated as an action wildcard" 0 --root "$FIX15" -# Case 16 (#69): the pre-fix shape - all three runtime copies grant the unused +# Case 16 (#69): the pre-fix shape - both runtime copies grant the unused # organizations:DescribeAccount / DescribeOrganization. Parity holds, so only # the runtime organizations allowlist catches it. FIX16="$TMP_BASE/dead-runtime-org-grants" seed_fixture "$FIX16" -sed 's/^\( *\)- organizations:ListAccounts$/&\ -\1- organizations:DescribeAccount\ -\1- organizations:DescribeOrganization/' \ - "$REPO_ROOT/cloudformation/stacks/CUDly/template.yaml" \ - > "$FIX16/cloudformation/stacks/CUDly/template.yaml" for f in terraform/modules/compute/aws/lambda/main.tf terraform/modules/compute/aws/fargate/main.tf; do sed 's/^\( *\)"organizations:ListAccounts",$/&\ \1"organizations:DescribeAccount",\ \1"organizations:DescribeOrganization",/' \ "$REPO_ROOT/$f" > "$FIX16/$f" done -for f in cloudformation/stacks/CUDly/template.yaml terraform/modules/compute/aws/lambda/main.tf terraform/modules/compute/aws/fargate/main.tf; do +for f in terraform/modules/compute/aws/lambda/main.tf terraform/modules/compute/aws/fargate/main.tf; do grep -q 'organizations:DescribeOrganization' "$FIX16/$f" || { echo "FAIL: case 16 fixture did not inject into $f"; (( fail++ )) || true; } done -run_case "runtime grants unused organizations actions in all three copies exits 1" 1 --root "$FIX16" +run_case "runtime grants unused organizations actions in both copies exits 1" 1 --root "$FIX16" # --- #1136: bare "*" Action written across lines, NotAction, lookalikes ------- -# Each case widens (or pretends to widen) one runtime copy only, so parity -# fails exactly when the extractor reports a bare "*" for it. +# Each case widens (or pretends to widen) one compared copy only, so parity +# fails exactly when the extractor reports a bare "*" for it. The YAML cases +# use the federation cross-account CloudFormation template, the YAML file in +# a parity group now that the CloudFormation install stack is retired (#1432). -CFN_STACK_REL=cloudformation/stacks/CUDly/template.yaml +FED_XACC_CFN_REL=iac/federation/aws-cross-account/cloudformation/template.yaml TF_LAMBDA_REL=terraform/modules/compute/aws/lambda/main.tf TF_FARGATE_REL=terraform/modules/compute/aws/fargate/main.tf @@ -211,11 +207,11 @@ new_fixture() { echo "$TMP_BASE/$1" } -# cfn_statement inserts stdin as a statement of the CFN stack's -# Lambda policy, ahead of the CostExplorer statement. A missing anchor counts +# cfn_statement inserts stdin as a statement of the federation +# cross-account role policy, ahead of the CostExplorer statement. A missing anchor counts # as a failure, so a moved anchor cannot turn a case into a silent no-op. cfn_statement() { - local file="$1/$CFN_STACK_REL" snip="$1/statement.snip" + local file="$1/$FED_XACC_CFN_REL" snip="$1/statement.snip" cat > "$snip" if awk -v snip="$snip" ' !done && index($0, "- Sid: CostExplorer") { while ((getline l < snip) > 0) print l; done = 1 } @@ -238,7 +234,7 @@ cfn_statement "$FIX17" <<'YAML' - "*" Resource: "*" YAML -run_case "bare \"*\" as a YAML block list item (CFN stack) exits 1" 1 --root "$FIX17" +run_case "bare \"*\" as a YAML block list item (federation CFN) exits 1" 1 --root "$FIX17" # Case 18: YAML compact block list (items at the key's indent), single-quoted. FIX18=$(new_fixture bare-star-yaml-compact) @@ -250,7 +246,7 @@ cfn_statement "$FIX18" <<'YAML' - '*' Resource: "*" YAML -run_case "bare '*' in a compact YAML block list (CFN stack) exits 1" 1 --root "$FIX18" +run_case "bare '*' in a compact YAML block list (federation CFN) exits 1" 1 --root "$FIX18" # Case 19: YAML flow list where "*" is not the first item. FIX19=$(new_fixture bare-star-yaml-flow) @@ -260,7 +256,7 @@ cfn_statement "$FIX19" <<'YAML' Action: [logs:CreateLogGroup, "*"] Resource: "*" YAML -run_case "bare \"*\" after another item in a YAML flow list (CFN stack) exits 1" 1 --root "$FIX19" +run_case "bare \"*\" after another item in a YAML flow list (federation CFN) exits 1" 1 --root "$FIX19" # Case 20: HCL policy-document actions list across lines. FIX20=$(new_fixture bare-star-hcl-actions) @@ -356,7 +352,7 @@ cfn_statement "$FIX24" <<'YAML' - "*" Resource: "*" YAML -run_case "NotAction \"*\" (CFN stack) is not an action wildcard" 0 --root "$FIX24" +run_case "NotAction \"*\" (federation CFN) is not an action wildcard" 0 --root "$FIX24" # Case 25: same for HCL not_actions and JSON/jsonencode NotAction. FIX25=$(new_fixture notaction-hcl-json) @@ -489,7 +485,7 @@ cfn_statement "$FIX28" <<'YAML' Effect: Allow Resource: "*" YAML -run_case "bare \"*\" Action opening the statement after an Action block list (CFN stack) exits 1" 1 --root "$FIX28" +run_case "bare \"*\" Action opening the statement after an Action block list (federation CFN) exits 1" 1 --root "$FIX28" echo "" echo "Results: ${pass} passed, ${fail} failed." diff --git a/terraform/modules/compute/aws/cross_account_sts_guard_test.go b/terraform/modules/compute/aws/cross_account_sts_guard_test.go index 7a9f9994e..8d47c46f3 100644 --- a/terraform/modules/compute/aws/cross_account_sts_guard_test.go +++ b/terraform/modules/compute/aws/cross_account_sts_guard_test.go @@ -11,11 +11,12 @@ // and the external ID are read off the same record, so a wrong selection // supplies both consistently. // -// The grant lives in three independently maintained copies (two Terraform -// modules and the shipped CloudFormation hub), which is why this guard -// DISCOVERS them by walking the tree instead of opening a list of three paths. -// A list of paths is the same defect one level up: a fourth copy added later is -// invisible to a sweep that only opens the files someone remembered to name. +// The grant lives in two independently maintained copies (the Lambda and +// Fargate Terraform modules; the CloudFormation hub was retired in #1432), +// which is why this guard DISCOVERS them by walking the tree instead of +// opening a list of paths. A list of paths is the same defect one level up: +// a third copy added later is invisible to a sweep that only opens the files +// someone remembered to name. // // The walking and the trust/identity classification live in // cross_account_sts_discovery_test.go; this file holds what is asserted about @@ -32,12 +33,11 @@ import ( // knownGrantSites must all be found by the walk. Not the guard's input (the // walk is), but its floor: if a refactor moves or renames a file, the walk -// silently inspects two sites instead of three and still reports "no +// silently inspects one site instead of two and still reports "no // violations", which is how a sweep passes by looking at nothing. var knownGrantSites = []string{ filepath.Join("terraform", "modules", "compute", "aws", "lambda", "main.tf"), filepath.Join("terraform", "modules", "compute", "aws", "fargate", "main.tf"), - filepath.Join("cloudformation", "stacks", "CUDly", "template.yaml"), } // accountConditionKey pins the grant to declared accounts; externalIDCondition @@ -99,7 +99,7 @@ var ( // green. This is a heuristic, not a parse. The honest alternative is an HCL // parser and a YAML parser for a check whose whole value is being cheap enough // to live in the module's own test package. It assumes Condition follows -// Action, which is true at all three sites and is the conventional ordering. +// Action, which is true at both sites and is the conventional ordering. const conditionProximityWindow = 800 // identityGrantRegion returns the slice of code running from the file's first @@ -121,7 +121,7 @@ func TestCrossAccountAssumeRoleIsPinnedToDeclaredAccounts(t *testing.T) { for _, want := range knownGrantSites { if _, ok := sites[want]; !ok { - t.Fatalf("%s grants no identity-policy sts:AssumeRole, but it is one of the three sites this guard exists to cover. Either the grant moved (point knownGrantSites at its new home) or it was deleted; a walk that no longer reaches it reports a clean result for a file it never opened. Found: %v", want, sortedKeys(sites)) + t.Fatalf("%s grants no identity-policy sts:AssumeRole, but it is one of the sites this guard exists to cover. Either the grant moved (point knownGrantSites at its new home) or it was deleted; a walk that no longer reaches it reports a clean result for a file it never opened. Found: %v", want, sortedKeys(sites)) } } @@ -164,9 +164,8 @@ func assertGrantPinned(t *testing.T, path, code string) { // so a wildcard denies everything rather than allowing everything -- it fails // closed, which is not the direction this guard defends. Format is already // enforced where an operator can actually get it wrong: the variable -// validation blocks in {lambda,fargate}/variables.tf and the AllowedPattern on -// the CloudFormation parameter. Asserting it a third time here bought a -// fragile regex protecting an unreachable bypass. +// validation blocks in {lambda,fargate}/variables.tf. Asserting it again here +// bought a fragile regex protecting an unreachable bypass. // TestAssumeRoleResourcePatternDoesNotRestrictTheAccount pins WHY the // condition above is load-bearing, by evaluating the Resource pattern the diff --git a/terraform/modules/compute/aws/function_url_cors_test.go b/terraform/modules/compute/aws/function_url_cors_test.go index 842453115..15f0df555 100644 --- a/terraform/modules/compute/aws/function_url_cors_test.go +++ b/terraform/modules/compute/aws/function_url_cors_test.go @@ -1,7 +1,8 @@ // Regression test for issue #310 (finding A02-015): the API serves PATCH // (PATCH /api/plans/{id}, PATCH /api/ladder/tranches/{id}), but the Lambda -// Function URL CORS config in both the Terraform lambda module and the -// CloudFormation stack listed only GET, POST, PUT and DELETE. When a Function +// Function URL CORS config in the Terraform lambda module (and in the +// CloudFormation install stack, since retired in #1432) listed only GET, POST, +// PUT and DELETE. When a Function // URL has a CORS config, AWS answers the preflight itself from that config, // so a cross-origin PATCH was refused before it reached the handler, whatever // the handler's own Access-Control-Allow-Methods said. @@ -20,12 +21,9 @@ import ( var apiRoutedMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE"} var ( - tfFunctionURLBlock = regexp.MustCompile(`(?s)resource\s+"aws_lambda_function_url"\s+"main"\s*\{.*?\n\}`) - tfCORSAllowMethods = regexp.MustCompile(`(?m)^\s*allow_methods\s*=\s*\[([^\]]*)\]`) - tfQuotedValue = regexp.MustCompile(`"([^"]*)"`) - cfnFunctionURLBlock = regexp.MustCompile(`(?s)Type:\s*AWS::Lambda::Url\n.*?\n \S`) - cfnAllowMethodsList = regexp.MustCompile(`(?m)^[ \t]*AllowMethods:[ \t]*\n((?:[ \t]*-[ \t].*\n)+)`) - cfnAllowMethodsEntry = regexp.MustCompile(`(?m)^\s*-\s*(\S+)\s*$`) + tfFunctionURLBlock = regexp.MustCompile(`(?s)resource\s+"aws_lambda_function_url"\s+"main"\s*\{.*?\n\}`) + tfCORSAllowMethods = regexp.MustCompile(`(?m)^\s*allow_methods\s*=\s*\[([^\]]*)\]`) + tfQuotedValue = regexp.MustCompile(`"([^"]*)"`) ) func assertAllowsRoutedMethods(t *testing.T, site string, methods []string) { @@ -62,25 +60,3 @@ func TestFunctionURLCORS_TerraformAllowsRoutedMethods(t *testing.T) { } assertAllowsRoutedMethods(t, "terraform lambda module", methods) } - -func TestFunctionURLCORS_CloudFormationAllowsRoutedMethods(t *testing.T) { - path := filepath.Join(repoRoot(t), "cloudformation", "stacks", "CUDly", "template.yaml") - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("reading %s: %v", path, err) - } - block := cfnFunctionURLBlock.FindString(string(data)) - if block == "" { - t.Fatal("CUDly template has no AWS::Lambda::Url resource") - } - m := cfnAllowMethodsList.FindStringSubmatch(block) - if m == nil { - t.Fatal("the AWS::Lambda::Url resource has no Cors AllowMethods list") - } - entries := cfnAllowMethodsEntry.FindAllStringSubmatch(m[1], -1) - methods := make([]string, 0, len(entries)) - for _, v := range entries { - methods = append(methods, v[1]) - } - assertAllowsRoutedMethods(t, "CloudFormation CUDly stack", methods) -} diff --git a/terraform/modules/compute/aws/retired_cfn_stack_test.go b/terraform/modules/compute/aws/retired_cfn_stack_test.go new file mode 100644 index 000000000..882ac3d7b --- /dev/null +++ b/terraform/modules/compute/aws/retired_cfn_stack_test.go @@ -0,0 +1,132 @@ +// Guards for issue #1432. The CloudFormation install stack still created +// DynamoDB tables and set no DB_HOST, so the Postgres-only server it deployed +// failed at startup on every request. The stack was retired in favor of the +// Terraform installs; these tests keep it from coming back, whole or as a +// stale pointer in the docs, scripts or tests. +package aws_test + +import ( + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// dynamoDBTable matches a DynamoDB table resource in CloudFormation or +// Terraform. The server keeps all state in Postgres, so a template that +// provisions one is deploying storage the server never reads. +var dynamoDBTable = regexp.MustCompile(`AWS::DynamoDB::Table\b|resource\s+"aws_dynamodb_table"`) + +// iacRoots hold every template the repo ships for deploying CUDly or for a +// customer to deploy. +var iacRoots = []string{ + "terraform", + "cloudformation", + "iac", + "arm", + filepath.Join("internal", "iacfiles", "templates"), +} + +var iacExtensions = map[string]bool{".tf": true, ".yaml": true, ".yml": true, ".json": true, ".tmpl": true} + +// retiredStackRef matches the retired stack's directory but not its +// CUDly-CrossAccount sibling, which stays. Split so this file does not match. +var retiredStackRef = regexp.MustCompile(`cloudformation/stacks/` + `CUDly(?:[^-\w]|$)`) + +// historicalPaths record past state on purpose and keep the old path. +var historicalPaths = []string{ + filepath.Join("docs", "audits"), + "known_issues", + "CHANGELOG.md", +} + +// skippedDirs are not part of the shipped source this guard reads. frontend/ +// has no IaC references and is large. +var skippedDirs = map[string]bool{".git": true, "node_modules": true, ".terraform": true, "frontend": true} + +const maxTextFileSize = 4 << 20 + +var textExtensions = map[string]bool{ + ".go": true, ".md": true, ".sh": true, ".tf": true, ".hcl": true, ".tmpl": true, + ".yaml": true, ".yml": true, ".json": true, ".txt": true, ".mk": true, "": true, +} + +// walkText calls visit with the repo-relative path and content of every file +// under dir (repo-relative) whose extension keep accepts. +func walkText(t *testing.T, dir string, keep map[string]bool, visit func(rel, content string)) { + t.Helper() + root := repoRoot(t) + err := filepath.WalkDir(filepath.Join(root, dir), func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, relErr := filepath.Rel(root, path) + if relErr != nil { + return relErr + } + if d.IsDir() { + if skippedDirs[d.Name()] { + return filepath.SkipDir + } + return nil + } + if !d.Type().IsRegular() || !keep[filepath.Ext(path)] { + return nil + } + // Extension-less files include locally built binaries; source and + // docs are far below this size. + if info, infoErr := d.Info(); infoErr != nil || info.Size() > maxTextFileSize { + return infoErr + } + data, readErr := os.ReadFile(path) // #nosec G304 -- paths come from walking the repo + if readErr != nil { + return readErr + } + visit(rel, string(data)) + return nil + }) + if err != nil { + t.Fatalf("walking %s: %v", dir, err) + } +} + +func TestNoIaCProvisionsDynamoDB(t *testing.T) { + for _, dir := range iacRoots { + files := 0 + walkText(t, dir, iacExtensions, func(rel, content string) { + files++ + if loc := dynamoDBTable.FindStringIndex(content); loc != nil { + line := strings.Count(content[:loc[0]], "\n") + 1 + t.Errorf("%s:%d provisions a DynamoDB table; the server stores everything in Postgres (DB_HOST), so this table is never read and its stack cannot start the server (#1432)", rel, line) + } + }) + if files == 0 { + t.Errorf("%s: no IaC files read; the guard would pass by looking at nothing", dir) + } + } +} + +func TestRetiredCloudFormationStackIsNotReferenced(t *testing.T) { + root := repoRoot(t) + if _, err := os.Stat(filepath.Join(root, "cloudformation", "stacks", "CUDly")); !os.IsNotExist(err) { + t.Errorf("the CloudFormation install stack directory exists again (stat err: %v); it was retired in #1432 because it cannot start the Postgres-only server. Use the Terraform installs", err) + } + files := 0 + walkText(t, ".", textExtensions, func(rel, content string) { + for _, h := range historicalPaths { + if rel == h || strings.HasPrefix(rel, h+string(filepath.Separator)) { + return + } + } + files++ + if loc := retiredStackRef.FindStringIndex(content); loc != nil { + line := strings.Count(content[:loc[0]], "\n") + 1 + t.Errorf("%s:%d still points at the retired CloudFormation install stack (#1432); point it at the Terraform installs instead", rel, line) + } + }) + if files < 100 { + t.Errorf("read only %d files; the walk is not reaching the repository", files) + } +} diff --git a/terraform/modules/compute/aws/runtime_iam_coverage_test.go b/terraform/modules/compute/aws/runtime_iam_coverage_test.go index d2ada298e..fe81277c7 100644 --- a/terraform/modules/compute/aws/runtime_iam_coverage_test.go +++ b/terraform/modules/compute/aws/runtime_iam_coverage_test.go @@ -1,5 +1,5 @@ // Detects CE/EC2 action names missing from runtime IaC, including gaps shared -// by all three flavors that check-aws-iam-parity.sh cannot detect (#1967/#1968). +// by both runtime flavors that check-aws-iam-parity.sh cannot detect (#1967/#1968). // This is source/action-presence coverage, not IAM evaluation: Effect, resource // scope, role attachment, and inline comments require separate review. Unused // grants (#1322) and other service namespaces are outside this guard's scope. @@ -41,12 +41,11 @@ var requiredDerivedActions = []string{ } // runtimeIAMFiles are the IaC files that grant the runtime role's IAM -// actions, relative to this package's directory (the three flavors compared +// actions, relative to this package's directory (the two flavors compared // by check-aws-iam-parity.sh comparison 1). var runtimeIAMFiles = []string{ filepath.Join("lambda", "main.tf"), filepath.Join("fargate", "main.tf"), - filepath.Join("..", "..", "..", "..", "cloudformation", "stacks", "CUDly", "template.yaml"), } // calledAction records where a derived action was first seen, so a failure @@ -237,7 +236,7 @@ func sdkImportAliases(file *ast.File) map[string]string { var grantedActionPattern = regexp.MustCompile(`(?:^|[^A-Za-z])((?:ce|ec2):[A-Z][A-Za-z]+)`) // hashCommentLinePattern matches a whole line whose first non-blank byte is -// #, the comment style both Terraform and CloudFormation YAML use here. +// #, the comment style Terraform uses here. var hashCommentLinePattern = regexp.MustCompile(`(?m)^[ \t]*#.*$`) // grantedActions extracts action names from path after stripping whole-line diff --git a/terraform/modules/compute/scheduled_task_cfn_test.go b/terraform/modules/compute/scheduled_task_cfn_test.go deleted file mode 100644 index ac61a9683..000000000 --- a/terraform/modules/compute/scheduled_task_cfn_test.go +++ /dev/null @@ -1,400 +0,0 @@ -// CloudFormation checks for issue #712: the CUDly stack must schedule every -// required server task with one EventBridge rule that invokes the stack's -// Lambda with {"action": ""}, an invoke permission gated exactly like -// its rule, and an enable parameter whose default follows the opt-in policy -// of the coverage test. The coverage test only finds the task names; this -// one parses the template. -package compute_test - -import ( - "encoding/json" - "fmt" - "os" - "regexp" - "slices" - "strings" - "testing" - - "gopkg.in/yaml.v3" - - "github.com/LeanerCloud/cloud-commitments-platform/internal/server" -) - -const ( - cfnTemplatePath = "../../../cloudformation/stacks/CUDly/template.yaml" - lambdaModuleVars = "aws/lambda/variables.tf" - // cfnLongestSuffix is the name suffix of the oldest rule. New rules must not - // be longer, so they fit the 64-char EventBridge limit for every stack name - // that rule already accepts. - cfnLongestSuffix = "-ScheduledExecution" -) - -// cfnAlwaysOn are the rules that predate #712 and have no enable parameter. -// Turning them into opt-in switches would change running stacks, so they stay. -var cfnAlwaysOn = map[string]bool{ - "collect_recommendations": true, - "process_scheduled_purchases": true, -} - -// cfnDefaultExceptions are tasks whose CloudFormation default is the opposite -// of the opt-in policy, with the reason. -var cfnDefaultExceptions = map[string]string{ - "process_scheduled_purchases": "the daily PurchaseCheck rule predates the opt-in policy; removing it would stop purchases on existing stacks", - "reap_stuck_purchases": "PurchaseCheck runs process only daily, so the reaper's 1h20m approved floor (#894) would still fail " + - "stranded approvals before process re-drives them", -} - -// cfnStack is a parsed template: Resources, Parameters and Conditions by -// logical id. Short-form tags are rewritten to their long form (!Ref to Ref, -// !X to Fn::X, !GetAtt "A.B" to Fn::GetAtt [A, B]). -type cfnStack struct { - Resources, Parameters, Conditions map[string]map[string]any -} - -func cfnValue(n *yaml.Node) any { - var v any - switch n.Kind { - case yaml.DocumentNode: - return cfnValue(n.Content[0]) - case yaml.AliasNode: - return cfnValue(n.Alias) - case yaml.MappingNode: - m := map[string]any{} - for i := 0; i+1 < len(n.Content); i += 2 { - m[n.Content[i].Value] = cfnValue(n.Content[i+1]) - } - v = m - case yaml.SequenceNode: - s := make([]any, 0, len(n.Content)) - for _, c := range n.Content { - s = append(s, cfnValue(c)) - } - v = s - default: - v = n.Value - } - if !strings.HasPrefix(n.Tag, "!") || strings.HasPrefix(n.Tag, "!!") { - return v - } - if n.Tag == "!Ref" { - return map[string]any{"Ref": v} - } - return map[string]any{"Fn::" + n.Tag[1:]: v} -} - -func parseCFN(src []byte) (*cfnStack, error) { - var root yaml.Node - if err := yaml.Unmarshal(src, &root); err != nil { - return nil, err - } - top, ok := cfnValue(&root).(map[string]any) - if !ok { - return nil, fmt.Errorf("template is not a mapping") - } - section := func(name string) map[string]map[string]any { - out := map[string]map[string]any{} - raw, _ := top[name].(map[string]any) - for id, v := range raw { - out[id], _ = v.(map[string]any) - } - return out - } - return &cfnStack{section("Resources"), section("Parameters"), section("Conditions")}, nil -} - -func props(res map[string]any) map[string]any { - p, _ := res["Properties"].(map[string]any) - return p -} - -// ref returns the logical id of {Ref: id}. -func ref(v any) string { - m, _ := v.(map[string]any) - s, _ := m["Ref"].(string) - return s -} - -// getAtt returns "Id.Attr" for both GetAtt forms. -func getAtt(v any) string { - m, _ := v.(map[string]any) - switch a := m["Fn::GetAtt"].(type) { - case string: - return a - case []any: - parts := make([]string, 0, len(a)) - for _, p := range a { - s, _ := p.(string) - parts = append(parts, s) - } - return strings.Join(parts, ".") - } - return "" -} - -func str(v any) string { - s, _ := v.(string) - return s -} - -// cfnRuleTask returns the task a rule's single target sends, or a problem. -func cfnRuleTask(id string, p map[string]any) (string, string) { - targets, _ := p["Targets"].([]any) - if len(targets) != 1 { - return "", fmt.Sprintf("%s: want exactly one target, got %d", id, len(targets)) - } - t, _ := targets[0].(map[string]any) - if getAtt(t["Arn"]) != "LambdaFunction.Arn" { - return "", id + ": target is not LambdaFunction.Arn" - } - var input map[string]any - if err := json.Unmarshal([]byte(str(t["Input"])), &input); err != nil || len(input) != 1 || str(input["action"]) == "" { - return "", fmt.Sprintf("%s: target Input must be exactly {\"action\": }, got %q", id, str(t["Input"])) - } - return str(input["action"]), "" -} - -var cfnNameRe = regexp.MustCompile(`^\$\{AWS::StackName\}(-[A-Za-z]+)$`) - -// cfnRules maps each scheduled task to the logical ids of its rules and -// reports malformed rules. -func cfnRules(s *cfnStack) (map[string][]string, []string) { - tasks := map[string][]string{} - var problems []string - names := map[string]string{} - for id, res := range s.Resources { - if res["Type"] != "AWS::Events::Rule" { - continue - } - p := props(res) - task, prob := cfnRuleTask(id, p) - if prob != "" { - problems = append(problems, prob) - continue - } - tasks[task] = append(tasks[task], id) - name, _ := p["Name"].(map[string]any) - m := cfnNameRe.FindStringSubmatch(str(name["Fn::Sub"])) - switch { - case m == nil: - problems = append(problems, id+`: Name must be !Sub "${AWS::StackName}-"`) - case len(m[1]) > len(cfnLongestSuffix): - problems = append(problems, fmt.Sprintf("%s: name suffix %s is longer than %s", id, m[1], cfnLongestSuffix)) - case names[m[1]] != "": - problems = append(problems, id+": name suffix "+m[1]+" also used by "+names[m[1]]) - default: - names[m[1]] = id - } - if p["State"] != "ENABLED" { - problems = append(problems, id+": State must be ENABLED; the enable parameter switches it") - } - } - return tasks, problems -} - -// cfnPermissionProblems checks that exactly one invoke permission points at -// the rule and that it carries the rule's condition. -func cfnPermissionProblems(s *cfnStack, ruleID string) []string { - var found []string - for id, res := range s.Resources { - if res["Type"] == "AWS::Lambda::Permission" && getAtt(props(res)["SourceArn"]) == ruleID+".Arn" { - found = append(found, id) - } - } - if len(found) != 1 { - return []string{fmt.Sprintf("%s: want exactly one Lambda permission with SourceArn %s.Arn, got %v", ruleID, ruleID, found)} - } - perm := s.Resources[found[0]] - p := props(perm) - var problems []string - if p["Action"] != "lambda:InvokeFunction" || p["Principal"] != "events.amazonaws.com" || ref(p["FunctionName"]) != "LambdaFunction" { - problems = append(problems, found[0]+": must grant lambda:InvokeFunction on LambdaFunction to events.amazonaws.com") - } - if perm["Condition"] != s.Resources[ruleID]["Condition"] { - problems = append(problems, fmt.Sprintf("%s: Condition %v differs from its rule's %v", found[0], perm["Condition"], s.Resources[ruleID]["Condition"])) - } - return problems -} - -// cfnEnableDefault resolves a rule's condition to its enable parameter's -// default: Fn::Equals [Ref: , "true"] on a "true"/"false" parameter. -func cfnEnableDefault(s *cfnStack, ruleID, cond string) (bool, string) { - eq, _ := s.Conditions[cond]["Fn::Equals"].([]any) - if len(eq) != 2 || eq[1] != "true" || ref(eq[0]) == "" { - return false, fmt.Sprintf("%s: condition %s must be Fn::Equals [Ref: , \"true\"]", ruleID, cond) - } - param := s.Parameters[ref(eq[0])] - allowed, _ := param["AllowedValues"].([]any) - if param == nil || !slices.Equal(allowed, []any{"true", "false"}) { - return false, fmt.Sprintf("%s: parameter %s must allow exactly \"true\" and \"false\"", ruleID, ref(eq[0])) - } - def := str(param["Default"]) - if def != "true" && def != "false" { - return false, fmt.Sprintf("%s: parameter %s default %q is not \"true\" or \"false\"", ruleID, ref(eq[0]), def) - } - return def == "true", "" -} - -var tfStringDefaultRe = regexp.MustCompile(`(?m)^\s*default\s*=\s*"([^"]*)"`) - -// cfnScheduleProblem checks that a switchable rule's schedule is a parameter -// whose default matches the Lambda module's default for the same task. -func cfnScheduleProblem(s *cfnStack, ruleID, task, lambdaVars string) string { - param := ref(props(s.Resources[ruleID])["ScheduleExpression"]) - if param == "" || s.Parameters[param] == nil { - return ruleID + ": ScheduleExpression must be a Ref to a stack parameter" - } - body, ok := blockOnce(stripComments(lambdaVars), `variable\s+"`+taskVars[task].schedule+`"\s*\{`) - m := tfStringDefaultRe.FindStringSubmatch(body) - if !ok || m == nil { - return ruleID + ": Lambda module has no string default for " + taskVars[task].schedule - } - if got := str(s.Parameters[param]["Default"]); got != m[1] { - return fmt.Sprintf("%s: %s defaults to %q, the Lambda module's %s to %q", ruleID, param, got, taskVars[task].schedule, m[1]) - } - return "" -} - -// cfnTaskProblems checks one required task's rule, permission, default and -// schedule. -func cfnTaskProblems(s *cfnStack, task string, ruleIDs []string, lambdaVars string) []string { - if len(ruleIDs) != 1 { - return []string{fmt.Sprintf("%s: want exactly one rule, got %v", task, ruleIDs)} - } - id := ruleIDs[0] - problems := cfnPermissionProblems(s, id) - cond := str(s.Resources[id]["Condition"]) - if (cond == "") != cfnAlwaysOn[task] { - return append(problems, fmt.Sprintf("%s: has condition %q; only the pre-existing rules %v may be unconditional", id, cond, cfnAlwaysOn)) - } - on := true - if cond != "" { - var prob string - if on, prob = cfnEnableDefault(s, id, cond); prob != "" { - return append(problems, prob) - } - if prob := cfnScheduleProblem(s, id, task, lambdaVars); prob != "" { - problems = append(problems, prob) - } - } - _, exception := cfnDefaultExceptions[task] - if want := defaultOn(task) != exception; on != want { - problems = append(problems, fmt.Sprintf("%s: on by default = %v, want %v (opt-in policy, exception %v)", task, on, want, exception)) - } - return problems -} - -// cfnProblems runs every check on a parsed template. -func cfnProblems(s *cfnStack, lambdaVars string) []string { - tasks, problems := cfnRules(s) - for task := range tasks { - if _, err := server.ParseScheduledTaskType(task); err != nil { - problems = append(problems, "rule schedules unknown task "+task) - } - } - for _, task := range requiredTasks() { - if slices.Contains(knownGaps["cloudformation"][dimTask], task) { - continue // the coverage test holds it to the documented gap - } - problems = append(problems, cfnTaskProblems(s, task, tasks[task], lambdaVars)...) - } - return problems -} - -func readCFN(t *testing.T) (tpl, lambdaVars string) { - t.Helper() - a, err := os.ReadFile(cfnTemplatePath) - if err != nil { - t.Fatal(err) - } - b, err := os.ReadFile(lambdaModuleVars) - if err != nil { - t.Fatal(err) - } - return string(a), string(b) -} - -func TestCloudFormationSchedulesEveryRequiredTask(t *testing.T) { - for task, reason := range cfnDefaultExceptions { - if !slices.Contains(requiredTasks(), task) || reason == "" { - t.Errorf("cfnDefaultExceptions[%s] must name a required task and give a reason", task) - } - } - tpl, lambdaVars := readCFN(t) - s, err := parseCFN([]byte(tpl)) - if err != nil { - t.Fatal(err) - } - for _, prob := range cfnProblems(s, lambdaVars) { - t.Error(prob) - } -} - -func TestCloudFormationScheduleCheckNegatives(t *testing.T) { - tpl, lambdaVars := readCFN(t) - for name, edit := range map[string]func(string) string{ - "rule removed": func(s string) string { - 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"`) - }, - "default-on task off by default": func(s string) string { - return regexp.MustCompile(`(EnableFinalizeRevocationsSchedule:\n Type: String\n Default: )"true"`).ReplaceAllString(s, `$1"false"`) - }, - "exception flipped to policy": func(s string) string { - return regexp.MustCompile(`(EnableReapStuckPurchasesSchedule:\n Type: String\n Default: )"false"`).ReplaceAllString(s, `$1"true"`) - }, - "permission points at another rule": func(s string) string { - return strings.Replace(s, "SourceArn: !GetAtt SendNotificationsRule.Arn", "SourceArn: !GetAtt FinalizeRevocationsRule.Arn", 1) - }, - "permission ungated": func(s string) string { - return strings.Replace(s, "Type: AWS::Lambda::Permission\n Condition: ScheduleAnalyticsCollect\n", "Type: AWS::Lambda::Permission\n", 1) - }, - "rule ungated": func(s string) string { - return strings.Replace(s, " Condition: ScheduleReapStuckPurchases\n Properties:\n Name", " Properties:\n Name", 1) - }, - "name too long": func(s string) string { return strings.Replace(s, `-ReapStuck"`, `-ReapStuckPurchasesNow"`, 1) }, - "name reused": func(s string) string { return strings.Replace(s, `-ReapStuck"`, `-Finalize"`, 1) }, - "literal schedule": func(s string) string { - return strings.Replace(s, "!Ref FinalizeRevocationsSchedule", `"rate(1 hour)"`, 1) - }, - "schedule drift": func(s string) string { - return strings.Replace(s, `Default: "rate(15 minutes)"`, `Default: "rate(1 minute)"`, 1) - }, - "wrong target": func(s string) string { - return strings.Replace(s, "Arn: !GetAtt LambdaFunction.Arn\n Input: '{\"action\": \"send_", "Arn: !Ref NotificationTopic\n Input: '{\"action\": \"send_", 1) - }, - "extra input key": func(s string) string { - return strings.Replace(s, `{"action": "analytics_collect"}`, `{"action": "analytics_collect", "x": 1}`, 1) - }, - "rule disabled": func(s string) string { - return strings.Replace(s, "ScheduleExpression: !Ref SendNotificationsSchedule\n State: ENABLED", "ScheduleExpression: !Ref SendNotificationsSchedule\n State: DISABLED", 1) - }, - "unknown task": func(s string) string { - return strings.Replace(s, `{"action": "fire_scheduled_purchases"}`, `{"action": "fire_purchases"}`, 1) - }, - "loose enable param": func(s string) string { - return strings.Replace(s, " - Ref: EnableSendNotificationsSchedule\n - \"true\"", " - Ref: EnableSendNotificationsSchedule\n - \"yes\"", 1) - }, - "legacy rule switched": func(s string) string { - return strings.Replace(s, " PurchaseCheckRule:\n Type: AWS::Events::Rule\n", " PurchaseCheckRule:\n Type: AWS::Events::Rule\n Condition: ScheduleSendNotifications\n", 1) - }, - } { - mutated := edit(tpl) - if mutated == tpl { - t.Errorf("%s: edit did not apply; update the fixture", name) - continue - } - s, err := parseCFN([]byte(mutated)) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - if len(cfnProblems(s, lambdaVars)) == 0 { - t.Errorf("%s: no problem reported", name) - } - } - long, err := parseCFN([]byte("Resources:\n P:\n Type: AWS::Lambda::Permission\n Properties:\n SourceArn:\n Fn::GetAtt: [R, Arn]\n")) - if err != nil || len(cfnPermissionProblems(long, "R")) == 0 || getAtt(props(long.Resources["P"])["SourceArn"]) != "R.Arn" { - t.Errorf("long-form Fn::GetAtt must resolve to R.Arn and the bare permission must be flagged: err = %v", err) - } -} diff --git a/terraform/modules/compute/scheduled_task_coverage_test.go b/terraform/modules/compute/scheduled_task_coverage_test.go index 44037d684..f9f38ca48 100644 --- a/terraform/modules/compute/scheduled_task_coverage_test.go +++ b/terraform/modules/compute/scheduled_task_coverage_test.go @@ -82,7 +82,6 @@ const ( var platforms = []platform{ {name: "aws-lambda", dir: "aws/lambda", aws: true, envDir: awsEnvDir, envBlock: "compute_lambda", lambda: true}, {name: "aws-fargate", dir: "aws/fargate", aws: true, envDir: awsEnvDir, envBlock: "compute_fargate"}, - {name: "cloudformation", dir: "../../../cloudformation/stacks/CUDly"}, {name: "gcp-cloud-run", dir: "gcp/cloud-run", envDir: gcpEnvDir, envBlock: "compute_cloud_run", httpSweep: "google_cloud_scheduler_job"}, {name: "gcp-gke", dir: "gcp/gke", envDir: gcpEnvDir, envBlock: "compute_gke", httpSweep: "google_cloud_scheduler_job"}, {name: "azure-container-apps", dir: "azure/container-apps", envDir: azureEnvDir, envBlock: "compute_container_apps", httpSweep: "azurerm_logic_app_action_custom"}, @@ -124,14 +123,12 @@ func stripComments(s string) string { // schedulerHeaders match the resources that actually send a task to the // server. A task name elsewhere (locals, env vars, permissions) is not a // schedule. A literal anywhere in the body counts, even in a description. -// cfnRule scopes a CloudFormation AWS::Events::Rule resource. var ( schedulerHeaders = []string{ `resource\s+"aws_cloudwatch_event_target"\s+"[^"]+"\s*\{`, `resource\s+"google_cloud_scheduler_job"\s+"[^"]+"\s*\{`, `resource\s+"azurerm_logic_app_action_custom"\s+"[^"]+"\s*\{`, } - cfnRule = regexp.MustCompile(`(?s)Type:\s*AWS::Events::Rule\b.*?(?:\n \S|\z)`) // Only a local's first-line value resolves, with no local-to-local recursion; // an unresolved local leaves the task unwired (an undocumented gap). localRef = regexp.MustCompile(`\blocal\.(\w+)`) @@ -140,11 +137,9 @@ var ( // sweepKeys matches the task keys of a `sweep_tasks` map: `for_each` targets // carry `action = each.key`, so the literal is the map key. sweepKeyRe = regexp.MustCompile(`(?m)^\s*(\w+)\s*=\s*\{`) - // literalRes are the task-name patterns of the contract test (schedulerRefs) - // plus the CloudFormation Input JSON. + // literalRes are the task-name patterns of the contract test (schedulerRefs). literalRes = func() []*regexp.Regexp { - res := make([]*regexp.Regexp, 1, 1+len(schedulerRefs)) - res[0] = regexp.MustCompile(`"action":\s*"([^"]+)"`) + res := make([]*regexp.Regexp, 0, len(schedulerRefs)) for _, ref := range schedulerRefs { res = append(res, ref.re) } @@ -188,10 +183,10 @@ func schedulerScopes(src, all string) []string { scopes = append(scopes, body) } } - return append(scopes, cfnRule.FindAllString(src, -1)...) + return scopes } -// scanDir reads the non-test *.tf files (or template.yaml) directly in dir. +// scanDir reads the non-test *.tf files directly in dir. // Non-recursive on purpose: that skips tests/ and .terraform/. func scanDir(dir string) (map[string]string, error) { entries, err := os.ReadDir(dir) @@ -200,7 +195,7 @@ func scanDir(dir string) (map[string]string, error) { } files := map[string]string{} for _, e := range entries { - if e.IsDir() || (filepath.Ext(e.Name()) != ".tf" && e.Name() != "template.yaml") { + if e.IsDir() || filepath.Ext(e.Name()) != ".tf" { continue } b, err := os.ReadFile(filepath.Join(dir, e.Name())) @@ -380,9 +375,6 @@ func TestCheckerNegatives(t *testing.T) { const aud = "locals {\n aud = \"x/api/scheduled/collect_recommendations\"\n}\n" job := func(body string) string { return "resource \"google_cloud_scheduler_job\" \"j\" {\n" + body + "\n}\n" } target := func(body string) string { return "resource \"aws_cloudwatch_event_target\" \"t\" {\n" + body + "\n}\n" } - cfn := func(typ string) string { - return " R:\n Type: " + typ + "\n Properties:\n Input: '{\"action\": \"finalize_revocations\"}'\n Other:\n Type: X\n" - } for name, c := range map[string]struct { src string wired []string @@ -401,8 +393,6 @@ func TestCheckerNegatives(t *testing.T) { "literal outside a scheduler block": {"resource \"aws_lambda_permission\" \"p\" {\n" + finalize + "\n}\n", nil, nil}, "unknown task in a scheduler block": {target(`input = jsonencode({ action = "bogus_task" })`), nil, []string{"bogus_task"}}, "sweep_tasks keys are wired": {"locals {\n sweep_tasks = {\n finalize_revocations = { rule = \"finalize\" }\n bogus_task = { rule = \"x\" }\n }\n}\n", []string{"finalize_revocations"}, []string{"bogus_task"}}, - "CloudFormation rule input": {cfn("AWS::Events::Rule"), []string{"finalize_revocations"}, nil}, - "CloudFormation literal outside a rule": {cfn("AWS::Lambda::Function"), nil, nil}, } { wired, unknown := wiredFrom(map[string]string{"a.tf": c.src}) if !slices.Equal(slices.Sorted(maps.Keys(wired)), c.wired) || !slices.Equal(unknown, c.unknown) {