diff --git a/.github/actions/deploy-lambda/action.yml b/.github/actions/deploy-lambda/action.yml new file mode 100644 index 0000000..b3e5adf --- /dev/null +++ b/.github/actions/deploy-lambda/action.yml @@ -0,0 +1,117 @@ +name: Deploy Lambda with CodeDeploy +description: Deploy a Lambda using Terragrunt + CodeDeploy (publish version, traffic shift, prune) + +inputs: + aws_oidc_role_arn: + description: AWS OIDC role to assume + required: true + + infra_version: + description: Git ref containing Terraform/Terragrunt config + required: true + + tg_directory: + description: Terragrunt directory for this lambda + required: true + + lambda_bucket: + description: "Bucket containing lambda zips" + required: true + + lambda_version: + description: Lambda artifact version (S3 prefix) + required: true + + lambda_keep: + description: Number of lambda versions to keep + required: true + +runs: + using: composite + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.infra_version }} + + - name: Get Terragrunt outputs + id: init + uses: chrispsheehan/terragrunt-aws-oidc-action@0.4.1 + with: + aws_oidc_role_arn: ${{ inputs.aws_oidc_role_arn }} + tg_directory: ${{ inputs.tg_directory }} + tg_action: init + + - name: Extract Terragrunt outputs + id: vars + shell: bash + env: + TG_OUTPUTS: ${{ steps.init.outputs.tg_outputs }} + run: | + echo "lambda_function_name=$(echo $TG_OUTPUTS | jq -r '.lambda_function_name.value')" >> $GITHUB_OUTPUT + echo "lambda_alias_name=$(echo $TG_OUTPUTS | jq -r '.lambda_alias_name.value')" >> $GITHUB_OUTPUT + echo "code_deploy_app_name=$(echo $TG_OUTPUTS | jq -r '.code_deploy_app_name.value')" >> $GITHUB_OUTPUT + echo "code_deploy_group_name=$(echo $TG_OUTPUTS | jq -r '.code_deploy_group_name.value')" >> $GITHUB_OUTPUT + + - name: Set AppSpec paths + id: appspec + shell: bash + run: | + lambda_zip_key="${{ inputs.lambda_version }}/${{ steps.vars.outputs.lambda_function_name }}.zip" + echo "lambda_appspec_key=$(echo $TG_OUTPUTS | jq -r '.lambda_zip_key.value')" >> $GITHUB_OUTPUT + echo "lambda_appspec_zip=$(echo $TG_OUTPUTS | jq -r '.lambda_function_name.value')" >> $GITHUB_OUTPUT + + - name: Get current lambda version + id: get-version + uses: chrispsheehan/just-aws-oidc-action@0.1.3 + env: + FUNCTION_NAME: ${{ steps.vars.outputs.lambda_function_name }} + ALIAS_NAME: ${{ steps.vars.outputs.lambda_alias_name }} + with: + aws_oidc_role_arn: ${{ inputs.aws_oidc_role_arn }} + just_action: lambda-get-version + + - name: Publish new lambda version + id: publish + uses: chrispsheehan/just-aws-oidc-action@0.1.3 + env: + BUCKET_NAME: ${{ inputs.lambda_bucket }} + FUNCTION_NAME: ${{ steps.vars.outputs.lambda_function_name }} + LAMBDA_ZIP_KEY: ${{ steps.vars.outputs.lambda_zip_key }} + with: + aws_oidc_role_arn: ${{ inputs.aws_oidc_role_arn }} + just_action: lambda-create-version + + - name: Upload AppSpec bundle + uses: chrispsheehan/just-aws-oidc-action@0.1.3 + env: + BUCKET_NAME: ${{ inputs.lambda_bucket }} + FUNCTION_NAME: ${{ steps.vars.outputs.lambda_function_name }} + ALIAS_NAME: ${{ steps.vars.outputs.lambda_alias_name }} + CURRENT_VERSION: ${{ steps.get-version.outputs.just_outputs }} + NEW_VERSION: ${{ steps.publish.outputs.just_outputs }} + APP_SPEC_FILE: ${{ github.workspace }}/appspec.yml + APP_SPEC_KEY: ${{ steps.vars.outputs.lambda_appspec_key }} + with: + aws_oidc_role_arn: ${{ inputs.aws_oidc_role_arn }} + just_action: lambda-upload-bundle + + - name: Run CodeDeploy + uses: chrispsheehan/just-aws-oidc-action@0.1.3 + env: + BUCKET_NAME: ${{ inputs.lambda_bucket }} + CODE_DEPLOY_APP_NAME: ${{ steps.vars.outputs.code_deploy_app_name }} + CODE_DEPLOY_GROUP_NAME: ${{ steps.vars.outputs.code_deploy_group_name }} + APP_SPEC_KEY: ${{ steps.vars.outputs.lambda_appspec_key }} + with: + aws_oidc_role_arn: ${{ inputs.aws_oidc_role_arn }} + just_action: lambda-deploy + + - name: Prune old lambda versions + uses: chrispsheehan/just-aws-oidc-action@0.1.3 + env: + KEEP: ${{ inputs.lambda_keep }} + FUNCTION_NAME: ${{ steps.vars.outputs.lambda_function_name }} + ALIAS_NAME: ${{ steps.vars.outputs.lambda_alias_name }} + with: + aws_oidc_role_arn: ${{ inputs.aws_oidc_role_arn }} + just_action: lambda-prune diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 663a8cc..44e27dd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,7 +38,7 @@ jobs: ref: ${{ inputs.version }} - name: Deploy code buckets - uses: chrispsheehan/terragrunt-aws-oidc-action@0.4.0 + uses: chrispsheehan/terragrunt-aws-oidc-action@0.4.1 id: code_action with: aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e19afe2..2b83dfd 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -34,7 +34,6 @@ permissions: env: TF_VAR_lambda_version: ${{ inputs.lambda_version }} AWS_OIDC_ROLE_ARN: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/aws-serverless-github-deploy-${{ inputs.environment }}-github-oidc-role - BUCKET_NAME: ${{ inputs.lambda_bucket }} jobs: setup: @@ -44,7 +43,7 @@ jobs: with: ref: ${{ inputs.infra_version }} - - uses: chrispsheehan/terragrunt-aws-oidc-action@0.4.0 + - uses: chrispsheehan/terragrunt-aws-oidc-action@0.4.1 with: aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} tg_directory: infra/live/${{ inputs.environment }}/aws/oidc @@ -52,86 +51,73 @@ jobs: - name: check Lambda version uses: chrispsheehan/just-aws-oidc-action@0.1.3 env: + BUCKET_NAME: ${{ inputs.lambda_bucket }} VERSION: ${{ inputs.lambda_version }} with: aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} just_action: check-version - api: + api-infra: needs: setup runs-on: ubuntu-latest - env: - APP_SPEC_FILE: ${{ github.workspace }}/appspec.yml - APP_SPEC_KEY: ${{ inputs.lambda_version }}/appspec.zip steps: - uses: actions/checkout@v4 with: ref: ${{ inputs.infra_version }} - - name: deploy api - id: deploy-api - uses: chrispsheehan/terragrunt-aws-oidc-action@0.4.0 + - name: Deploy api infra + id: deploy + uses: chrispsheehan/terragrunt-aws-oidc-action@0.4.1 with: aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} tg_directory: infra/live/${{ inputs.environment }}/aws/api - - name: get api variables - id: get-api-vars - env: - TG_OUTPUTS: ${{ steps.deploy-api.outputs.tg_outputs }} - run: | - echo "lambda_zip_key=$(echo $TG_OUTPUTS | jq -r '.lambda_zip_key.value')" >> $GITHUB_OUTPUT - echo "lambda_function_name=$(echo $TG_OUTPUTS | jq -r '.lambda_function_name.value')" >> $GITHUB_OUTPUT - echo "lambda_alias_name=$(echo $TG_OUTPUTS | jq -r '.lambda_alias_name.value')" >> $GITHUB_OUTPUT - echo "code_deploy_app_name=$(echo $TG_OUTPUTS | jq -r '.code_deploy_app_name.value')" >> $GITHUB_OUTPUT - echo "code_deploy_group_name=$(echo $TG_OUTPUTS | jq -r '.code_deploy_group_name.value')" >> $GITHUB_OUTPUT - - - name: get lambda version - id: lambda-get-version - uses: chrispsheehan/just-aws-oidc-action@0.1.3 - env: - FUNCTION_NAME: ${{ steps.get-api-vars.outputs.lambda_function_name }} - ALIAS_NAME: ${{ steps.get-api-vars.outputs.lambda_alias_name }} + api: + needs: api-infra + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 with: - aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} - just_action: lambda-get-version + ref: ${{ inputs.infra_version }} - - name: create lambda version - id: lambda-create-version - uses: chrispsheehan/just-aws-oidc-action@0.1.3 - env: - LAMBDA_ZIP_KEY: ${{ steps.get-api-vars.outputs.lambda_zip_key }} - FUNCTION_NAME: ${{ steps.get-api-vars.outputs.lambda_function_name }} + - uses: ./.github/actions/deploy-lambda with: aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} - just_action: lambda-create-version + infra_version: ${{ inputs.infra_version }} + tg_directory: infra/live/${{ inputs.environment }}/aws/api + lambda_bucket: ${{ inputs.lambda_bucket }} + lambda_version: ${{ inputs.lambda_version }} + lambda_keep: ${{ inputs.lambda_keep }} - - name: Prepare and upload AppSpec File to s3 - uses: chrispsheehan/just-aws-oidc-action@0.1.3 - env: - FUNCTION_NAME: ${{ steps.get-api-vars.outputs.lambda_function_name }} - ALIAS_NAME: ${{ steps.get-api-vars.outputs.lambda_alias_name }} - CURRENT_VERSION: ${{ steps.lambda-get-version.outputs.just_outputs }} - NEW_VERSION: ${{ steps.lambda-create-version.outputs.just_outputs }} - with: - aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} - just_action: lambda-upload-bundle + # consumer-infra: + # needs: setup + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + # with: + # ref: ${{ inputs.infra_version }} - - name: deploy lambda - uses: chrispsheehan/just-aws-oidc-action@0.1.3 - env: - CODE_DEPLOY_APP_NAME: ${{ steps.get-api-vars.outputs.code_deploy_app_name }} - CODE_DEPLOY_GROUP_NAME: ${{ steps.get-api-vars.outputs.code_deploy_group_name }} - with: - aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} - just_action: lambda-deploy + # - name: Deploy consumer infra + # id: deploy + # uses: chrispsheehan/terragrunt-aws-oidc-action@0.4.1 + # with: + # aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} + # tg_directory: infra/live/${{ inputs.environment }}/aws/consumer + + # consumer: + # needs: consumer-infra + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + # with: + # ref: ${{ inputs.infra_version }} + + # - uses: ./.github/actions/deploy-lambda + # with: + # aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} + # infra_version: ${{ inputs.infra_version }} + # tg_directory: infra/live/${{ inputs.environment }}/aws/consumer + # lambda_bucket: ${{ inputs.lambda_bucket }} + # lambda_version: ${{ inputs.lambda_version }} + # lambda_keep: ${{ inputs.lambda_keep }} - - name: prune lambda - uses: chrispsheehan/just-aws-oidc-action@0.1.3 - env: - KEEP: ${{ inputs.lambda_keep }} - FUNCTION_NAME: ${{ steps.get-api-vars.outputs.lambda_function_name }} - ALIAS_NAME: ${{ steps.get-api-vars.outputs.lambda_alias_name }} - with: - aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} - just_action: lambda-prune diff --git a/.github/workflows/destroy.yml b/.github/workflows/destroy.yml index 341fd81..90465fa 100644 --- a/.github/workflows/destroy.yml +++ b/.github/workflows/destroy.yml @@ -29,21 +29,34 @@ jobs: - uses: actions/checkout@v4 - name: Destroy backend - uses: chrispsheehan/terragrunt-aws-oidc-action@0.4.0 + uses: chrispsheehan/terragrunt-aws-oidc-action@0.4.1 with: aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} tg_directory: infra/live/${{ inputs.environment }}/aws/api tg_action: destroy + consumer: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Destroy backend + uses: chrispsheehan/terragrunt-aws-oidc-action@0.4.1 + with: + aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} + tg_directory: infra/live/${{ inputs.environment }}/aws/consumer + tg_action: destroy + build: needs: - api + - consumer runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Destroy code - uses: chrispsheehan/terragrunt-aws-oidc-action@0.4.0 + uses: chrispsheehan/terragrunt-aws-oidc-action@0.4.1 with: aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} tg_directory: infra/live/${{ inputs.environment }}/aws/code_bucket diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index 14a6598..2d1695a 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -14,9 +14,20 @@ jobs: environment: dev version: ${{ github.sha }} + lambdas: + needs: build + uses: ./.github/workflows/infra_deploy.yml + with: + environment: dev + infra_version: ${{ github.sha }} + lambda_bucket: ${{ needs.build.outputs.lambda_bucket }} + lambda_version: ${{ github.sha }} + dev: uses: ./.github/workflows/deploy.yml - needs: build + needs: + - build + - lambdas with: environment: dev infra_version: ${{ github.sha }} diff --git a/.github/workflows/get_build.yml b/.github/workflows/get_build.yml index 1480ca2..f3e45ff 100644 --- a/.github/workflows/get_build.yml +++ b/.github/workflows/get_build.yml @@ -38,7 +38,7 @@ jobs: ref: ${{ inputs.version }} - name: Get code bucket outputs - uses: chrispsheehan/terragrunt-aws-oidc-action@0.4.0 + uses: chrispsheehan/terragrunt-aws-oidc-action@0.4.1 id: code_action with: aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} diff --git a/.github/workflows/infra_deploy.yml b/.github/workflows/infra_deploy.yml new file mode 100644 index 0000000..d65a298 --- /dev/null +++ b/.github/workflows/infra_deploy.yml @@ -0,0 +1,84 @@ +on: + workflow_call: + inputs: + environment: + description: environment reference i.e. 'prod' or 'dev' + required: true + type: string + infra_version: + description: "Version of infrastructure (terraform) to be deployed" + required: true + type: string + lambda_bucket: + description: "Bucket containing lambda zips" + required: true + type: string + lambda_version: + description: "Valid lambda version" + required: true + type: string + + +concurrency: # only run one instance of workflow at any one time + group: ${{ github.workflow }}-${{ inputs.environment }} + cancel-in-progress: false + +permissions: + id-token: write + contents: write + +env: + TF_VAR_lambda_version: ${{ inputs.lambda_version }} + AWS_OIDC_ROLE_ARN: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/aws-serverless-github-deploy-${{ inputs.environment }}-github-oidc-role + +jobs: + setup: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.infra_version }} + + - uses: chrispsheehan/terragrunt-aws-oidc-action@0.4.1 + with: + aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} + tg_directory: infra/live/${{ inputs.environment }}/aws/oidc + + - name: check Lambda version + uses: chrispsheehan/just-aws-oidc-action@0.1.3 + env: + BUCKET_NAME: ${{ inputs.lambda_bucket }} + VERSION: ${{ inputs.lambda_version }} + with: + aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} + just_action: check-version + + api: + needs: setup + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.infra_version }} + + - name: Deploy api infra + id: deploy + uses: chrispsheehan/terragrunt-aws-oidc-action@0.4.1 + with: + aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} + tg_directory: infra/live/${{ inputs.environment }}/aws/api + + consumer: + needs: setup + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.infra_version }} + + - name: Deploy consumer infra + id: deploy + uses: chrispsheehan/terragrunt-aws-oidc-action@0.4.1 + with: + aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} + tg_directory: infra/live/${{ inputs.environment }}/aws/consumer diff --git a/backend/consumer/lambda_handler.py b/backend/consumer/lambda_handler.py new file mode 100644 index 0000000..163a1c5 --- /dev/null +++ b/backend/consumer/lambda_handler.py @@ -0,0 +1,65 @@ +import json +from typing import List, Dict + +CHUNK_SIZE = 50 + + +def chunk(items: List[Dict], size: int): + """Yield successive chunks from a list.""" + for i in range(0, len(items), size): + yield items[i:i + size] + + +def process_message(record: Dict): + """ + Process a single SQS message. + Raise an exception to mark it as failed. + """ + body = record["body"] + + # If your messages are JSON: + # payload = json.loads(body) + + # TODO: your business logic here + print({ + "message_id": record["messageId"], + "body_preview": body[:200] + }) + + +def process_chunk(records: List[Dict]) -> List[str]: + """ + Process a chunk of messages. + Returns a list of messageIds that failed. + """ + failed_message_ids = [] + + for record in records: + try: + process_message(record) + except Exception as exc: + print(f"Failed processing message {record['messageId']}: {exc}") + failed_message_ids.append(record["messageId"]) + + return failed_message_ids + + +def lambda_handler(event, context): + """ + AWS Lambda entry point. + Uses partial batch response so only failed messages are retried. + """ + records = event.get("Records", []) + batch_item_failures = [] + + for records_chunk in chunk(records, CHUNK_SIZE): + failed_ids = process_chunk(records_chunk) + + for message_id in failed_ids: + batch_item_failures.append({ + "itemIdentifier": message_id + }) + + return { + "batchItemFailures": batch_item_failures + } diff --git a/backend/consumer/requirements.txt b/backend/consumer/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/infra/live/dev/aws/consumer/terragrunt.hcl b/infra/live/dev/aws/consumer/terragrunt.hcl new file mode 100644 index 0000000..211f485 --- /dev/null +++ b/infra/live/dev/aws/consumer/terragrunt.hcl @@ -0,0 +1,7 @@ +include { + path = find_in_parent_folders("root.hcl") +} + +terraform { + source = "../../../../modules//aws//consumer" +} diff --git a/infra/live/global_vars.hcl b/infra/live/global_vars.hcl index 571ab7f..5b61d4b 100644 --- a/infra/live/global_vars.hcl +++ b/infra/live/global_vars.hcl @@ -8,7 +8,8 @@ locals { "apigateway:*", "codedeploy:*", "application-autoscaling:*", - "cloudwatch:*" + "cloudwatch:*", + "sqs:*" ] } diff --git a/infra/live/prod/aws/consumer/terragrunt.hcl b/infra/live/prod/aws/consumer/terragrunt.hcl new file mode 100644 index 0000000..211f485 --- /dev/null +++ b/infra/live/prod/aws/consumer/terragrunt.hcl @@ -0,0 +1,7 @@ +include { + path = find_in_parent_folders("root.hcl") +} + +terraform { + source = "../../../../modules//aws//consumer" +} diff --git a/infra/modules/aws/lambda/data.tf b/infra/modules/aws/_shared/lambda/data.tf similarity index 95% rename from infra/modules/aws/lambda/data.tf rename to infra/modules/aws/_shared/lambda/data.tf index dfcb8a7..c8d5af5 100644 --- a/infra/modules/aws/lambda/data.tf +++ b/infra/modules/aws/_shared/lambda/data.tf @@ -49,7 +49,7 @@ data "aws_iam_policy_document" "codedeploy_lambda" { effect = "Allow" actions = ["s3:GetObject", "s3:GetObjectVersion"] resources = [ - "arn:aws:s3:::${data.aws_s3_bucket.lambda_code.bucket}/${var.lambda_version}/*" + "arn:aws:s3:::${data.aws_s3_bucket.lambda_code.bucket}/*" ] } diff --git a/infra/modules/aws/_shared/lambda/locals.tf b/infra/modules/aws/_shared/lambda/locals.tf new file mode 100644 index 0000000..0e25064 --- /dev/null +++ b/infra/modules/aws/_shared/lambda/locals.tf @@ -0,0 +1,41 @@ +locals { + lambda_runtime = "python3.12" + lambda_handler = "lambda_handler.lambda_handler" + compute_platform = "Lambda" + + lambda_name = "${var.environment}-${var.project_name}-${var.lambda_name}" + lambda_code_zip_key = "${var.lambda_version}/${var.lambda_name}.zip" + lambda_appspec_key = "appspecs/${var.lambda_name}-appspec.zip" + + deploy_all_at_once_type = "AllAtOnce" + deploy_canary_type = "TimeBasedCanary" + deploy_linear_type = "TimeBasedLinear" + + deploy_config_type_map = { + all_at_once = local.deploy_all_at_once_type + canary = local.deploy_canary_type + linear = local.deploy_linear_type + } + deploy_config = { + type = local.deploy_config_type_map[var.deployment_config.strategy] + percent = var.deployment_config.percentage + minutes = var.deployment_config.interval_minutes + } + + fixed_mode = try(var.provisioned_config.fixed != null, true) && try(var.provisioned_config.fixed > 0, false) + auto_scale_mode = try(var.provisioned_config.auto_scale != null, false) + sqs_scale_mode = try(var.provisioned_config.sqs_scale != null, false) + + pc_fixed_count = try(var.provisioned_config.fixed, 0) + pc_reserved_count = try(var.provisioned_config.reserved_concurrency, 0) + + pc_min_capacity = try(var.provisioned_config.sqs_scale.min, var.provisioned_config.auto_scale.min, 0) + pc_max_capacity = try(var.provisioned_config.sqs_scale.max, var.provisioned_config.auto_scale.max, 0) + + pc_scale_in_cooldown_seconds = try(var.provisioned_config.auto_scale.scale_in_cooldown_seconds, var.provisioned_config.sqs_scale.scale_in_cooldown_seconds, 60) + pc_scale_out_cooldown_seconds = try(var.provisioned_config.auto_scale.scale_out_cooldown_seconds, var.provisioned_config.sqs_scale.scale_out_cooldown_seconds, 60) + + pc_trigger_percent = try(var.provisioned_config.auto_scale.trigger_percent, 70) / 100 + pc_sqs_target_visible_messages = try(var.provisioned_config.sqs_scale.visible_messages, 0) + pc_sqs_queue_name = try(var.provisioned_config.sqs_scale.queue_name, "") +} diff --git a/infra/modules/aws/lambda/main.tf b/infra/modules/aws/_shared/lambda/main.tf similarity index 74% rename from infra/modules/aws/lambda/main.tf rename to infra/modules/aws/_shared/lambda/main.tf index e9301a0..9343337 100644 --- a/infra/modules/aws/lambda/main.tf +++ b/infra/modules/aws/_shared/lambda/main.tf @@ -3,12 +3,21 @@ resource "aws_iam_role" "iam_for_lambda" { assume_role_policy = data.aws_iam_policy_document.assume_role.json } +resource "aws_iam_role_policy_attachment" "additional_iam_attachments" { + for_each = { for idx, arn in var.additional_policy_arns : idx => arn } + + role = aws_iam_role.iam_for_lambda.name + policy_arn = each.value +} + resource "aws_lambda_function" "lambda" { function_name = local.lambda_name role = aws_iam_role.iam_for_lambda.arn handler = local.lambda_handler runtime = local.lambda_runtime + reserved_concurrent_executions = local.pc_reserved_count + s3_bucket = data.aws_s3_bucket.lambda_code.bucket s3_key = local.lambda_code_zip_key @@ -53,7 +62,7 @@ resource "aws_lambda_provisioned_concurrency_config" "alias_pc_fixed" { resource "aws_codedeploy_app" "app" { name = "${local.lambda_name}-app" - compute_platform = "Lambda" + compute_platform = local.compute_platform } resource "aws_iam_role" "code_deploy_role" { @@ -69,7 +78,7 @@ resource "aws_iam_role_policy" "cd_lambda" { resource "aws_codedeploy_deployment_config" "lambda_config" { deployment_config_name = "${local.lambda_name}-deploy-config" - compute_platform = "Lambda" + compute_platform = local.compute_platform traffic_routing_config { type = local.deploy_config.type @@ -121,7 +130,7 @@ resource "aws_appautoscaling_target" "pc_target" { } resource "aws_appautoscaling_policy" "pc_policy" { - count = local.fixed_mode ? 0 : 1 + count = local.auto_scale_mode ? 1 : 0 name = "${local.lambda_name}-pc-tt" policy_type = "TargetTrackingScaling" resource_id = aws_appautoscaling_target.pc_target.resource_id @@ -137,3 +146,33 @@ resource "aws_appautoscaling_policy" "pc_policy" { } } } + +resource "aws_appautoscaling_policy" "pc_sqs_policy" { + count = local.sqs_scale_mode ? 1 : 0 + name = "${local.lambda_name}-pc-sqs-depth-tt" + policy_type = "TargetTrackingScaling" + resource_id = aws_appautoscaling_target.pc_target.resource_id + scalable_dimension = aws_appautoscaling_target.pc_target.scalable_dimension + service_namespace = aws_appautoscaling_target.pc_target.service_namespace + + target_tracking_scaling_policy_configuration { + # Example: try to keep ~1000 visible messages in the queue. + # Tune this based on your batch size + desired drain speed. + target_value = local.pc_sqs_target_visible_messages + + scale_in_cooldown = local.pc_scale_in_cooldown_seconds + scale_out_cooldown = local.pc_scale_out_cooldown_seconds + + customized_metric_specification { + metric_name = "ApproximateNumberOfMessagesVisible" + namespace = "AWS/SQS" + statistic = "Average" + unit = "Count" + + dimensions { + name = "QueueName" + value = local.pc_sqs_queue_name + } + } + } +} diff --git a/infra/modules/aws/lambda/outputs.tf b/infra/modules/aws/_shared/lambda/outputs.tf similarity index 91% rename from infra/modules/aws/lambda/outputs.tf rename to infra/modules/aws/_shared/lambda/outputs.tf index 4fe1631..d6fea95 100644 --- a/infra/modules/aws/lambda/outputs.tf +++ b/infra/modules/aws/_shared/lambda/outputs.tf @@ -14,6 +14,10 @@ output "alias_name" { value = aws_lambda_alias.live.name } +output "alias_arn" { + value = aws_lambda_alias.live.arn +} + output "cloudwatch_log_group" { value = aws_cloudwatch_log_group.lambda_cloudwatch_group.name } diff --git a/infra/modules/aws/_shared/lambda/variables.tf b/infra/modules/aws/_shared/lambda/variables.tf new file mode 100644 index 0000000..d485464 --- /dev/null +++ b/infra/modules/aws/_shared/lambda/variables.tf @@ -0,0 +1,216 @@ +### start of static vars set in root.hcl ### +variable "project_name" { + type = string + description = "Project name used in naming resources" +} + +variable "environment" { + type = string + description = "Environment reference used in naming resources i.e. 'dev'" +} + +variable "lambda_bucket" { + type = string + description = "Lambda bucket where the code zip(s) are uploaded to" +} +### end of static vars set in root.hcl ### + + +### start of dynamic vars required for resources ### +variable "lambda_name" { + type = string + description = "Must match the name of the zip - formed in /backend directory i.e. /backend/api = 'api'" +} + +variable "lambda_version" { + type = string + description = "Lambda code version to be deployed. Used in locating zip file keys" +} +### end of dynamic vars required for resources ### + + +variable "log_retention_days" { + type = number + description = "Number of days to hold logs" + default = 1 +} + +variable "additional_policy_arns" { + description = "List of IAM policy ARNs to attach to the role" + type = list(string) + default = [] +} + +variable "deployment_config" { + description = "Traffic shifting: all_at_once | canary | linear" + type = object({ + strategy = string # all_at_once | canary | linear + percentage = optional(number) # 1..99 (req for canary/linear) + interval_minutes = optional(number) # >=1 (req for canary/linear) + }) + default = { strategy = "all_at_once" } + + validation { + condition = ( + contains(["all_at_once", "canary", "linear"], var.deployment_config.strategy) + && + ( + var.deployment_config.strategy == "all_at_once" + || + ( + coalesce(var.deployment_config.percentage, 0) >= 1 + && coalesce(var.deployment_config.percentage, 0) <= 99 + && coalesce(var.deployment_config.interval_minutes, 0) >= 1 + ) + ) + ) + error_message = "Use strategy all_at_once | canary | linear. For canary/linear, set percentage (1..99) and interval_minutes (>=1)." + } +} + +variable "provisioned_config" { + description = "Either fixed provisioned concurrency (fixed) or autoscaled (auto_scale); omit/zero = none" + type = object({ + fixed = optional(number) # 0/omit = off, >0 = fixed PC + reserved_concurrency = optional(number) # 0/omit = no concurrency limit, >0 = limited concurrency + + auto_scale = optional(object({ + min = number + max = number + trigger_percent = optional(number) + scale_in_cooldown_seconds = optional(number) + scale_out_cooldown_seconds = optional(number) + })) + + sqs_scale = optional(object({ + min = number + max = number + visible_messages = number + queue_name = optional(string) + scale_in_cooldown_seconds = optional(number) + scale_out_cooldown_seconds = optional(number) + })) + }) + default = { + fixed = 0 + reserved_concurrency = 1 + } + + validation { + condition = ( + var.provisioned_config.fixed == null || var.provisioned_config.fixed == 0 + ? true + : ( + var.provisioned_config.reserved_concurrency != null && + var.provisioned_config.reserved_concurrency > var.provisioned_config.fixed + ) + ) + error_message = "When provisioned_config.fixed > 0, provisioned_config.reserved_concurrency must be set and greater than fixed to avoid Lambda throttling." + } + + validation { + condition = !( + ( + coalesce(var.provisioned_config.fixed, 0) > 0 + ) && ( + var.provisioned_config.auto_scale != null || + var.provisioned_config.sqs_scale != null + ) + ) && !( + var.provisioned_config.auto_scale != null && + var.provisioned_config.sqs_scale != null + ) + error_message = "Specify only one of 'fixed', 'auto_scale', or 'sqs_scale' (or none)." + } + + validation { + condition = ( + ( + var.provisioned_config.auto_scale != null + ? var.provisioned_config.auto_scale.max > var.provisioned_config.auto_scale.min + : true + ) + && + ( + var.provisioned_config.sqs_scale != null + ? var.provisioned_config.sqs_scale.max > var.provisioned_config.sqs_scale.min + : true + ) + ) + error_message = "When auto_scale or sqs_scale is set, 'max' must be greater than 'min'." + } + + validation { + condition = ( + var.provisioned_config.auto_scale != null + ? (var.provisioned_config.auto_scale.trigger_percent > 0 && var.provisioned_config.auto_scale.trigger_percent < 100) + : true + ) + error_message = "When autoscale.trigger_percent, must be > 0 && < 100" + } + + validation { + condition = ( + var.provisioned_config.auto_scale != null + ? ( + var.provisioned_config.auto_scale.scale_in_cooldown_seconds != null && + var.provisioned_config.auto_scale.scale_out_cooldown_seconds != null && + + var.provisioned_config.auto_scale.scale_in_cooldown_seconds >= 60 && + var.provisioned_config.auto_scale.scale_out_cooldown_seconds >= 60 + ) + : true + ) + error_message = "When auto_scale is set, both scale_in_cooldown_seconds and scale_out_cooldown_seconds must be specified and each must be at least 60 seconds." + } + + validation { + condition = ( + var.provisioned_config.sqs_scale != null + ? ( + var.provisioned_config.sqs_scale.min >= 0 && + var.provisioned_config.sqs_scale.max > var.provisioned_config.sqs_scale.min && + floor(var.provisioned_config.sqs_scale.min) == var.provisioned_config.sqs_scale.min && + floor(var.provisioned_config.sqs_scale.max) == var.provisioned_config.sqs_scale.max + ) + : true + ) + error_message = "When sqs_scale is set, 'min' must be an integer >= 0 and 'max' must be an integer greater than 'min'." + } + + validation { + condition = ( + var.provisioned_config.sqs_scale != null + ? ( + var.provisioned_config.sqs_scale.visible_messages > 0 && + floor(var.provisioned_config.sqs_scale.visible_messages) == var.provisioned_config.sqs_scale.visible_messages + ) + : true + ) + error_message = "When sqs_scale is set, 'visible_messages' must be a positive integer." + } + + validation { + condition = ( + var.provisioned_config.sqs_scale != null && var.provisioned_config.sqs_scale.queue_name != null + ? length(trimspace(var.provisioned_config.sqs_scale.queue_name)) > 0 + : true + ) + error_message = "When sqs_scale.queue_name is set, it must be a non-empty string." + } + + validation { + condition = ( + var.provisioned_config.sqs_scale != null + ? ( + var.provisioned_config.sqs_scale.scale_in_cooldown_seconds != null && + var.provisioned_config.sqs_scale.scale_out_cooldown_seconds != null && + + var.provisioned_config.sqs_scale.scale_in_cooldown_seconds >= 60 && + var.provisioned_config.sqs_scale.scale_out_cooldown_seconds >= 60 + ) + : true + ) + error_message = "When sqs_scale is set, both scale_in_cooldown_seconds and scale_out_cooldown_seconds must be specified and each must be at least 60 seconds." + } +} \ No newline at end of file diff --git a/infra/modules/aws/_shared/sqs/data.tf b/infra/modules/aws/_shared/sqs/data.tf new file mode 100644 index 0000000..87c2f8e --- /dev/null +++ b/infra/modules/aws/_shared/sqs/data.tf @@ -0,0 +1,24 @@ +data "aws_iam_policy_document" "sqs_read" { + statement { + effect = "Allow" + actions = [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes", + "sqs:ChangeMessageVisibility" + ] + resources = [aws_sqs_queue.queue.arn] + } +} + +data "aws_iam_policy_document" "sqs_write" { + statement { + effect = "Allow" + actions = [ + "sqs:SendMessage", + "sqs:SendMessageBatch", + "sqs:GetQueueAttributes" + ] + resources = [aws_sqs_queue.queue.arn] + } +} diff --git a/infra/modules/aws/_shared/sqs/main.tf b/infra/modules/aws/_shared/sqs/main.tf new file mode 100644 index 0000000..2d59ae2 --- /dev/null +++ b/infra/modules/aws/_shared/sqs/main.tf @@ -0,0 +1,16 @@ +resource "aws_sqs_queue" "queue" { + name = var.sqs_queue_name + delay_seconds = 0 + message_retention_seconds = 86400 + receive_wait_time_seconds = 10 +} + +resource "aws_iam_policy" "queue_read_policy" { + name = "${var.sqs_queue_name}-sqs-read-policy" + policy = data.aws_iam_policy_document.sqs_read.json +} + +resource "aws_iam_policy" "queue_write_policy" { + name = "${var.sqs_queue_name}-sqs-write-policy" + policy = data.aws_iam_policy_document.sqs_write.json +} diff --git a/infra/modules/aws/_shared/sqs/outputs.tf b/infra/modules/aws/_shared/sqs/outputs.tf new file mode 100644 index 0000000..35ac0bc --- /dev/null +++ b/infra/modules/aws/_shared/sqs/outputs.tf @@ -0,0 +1,19 @@ +output "sqs_queue_name" { + value = aws_sqs_queue.queue.name +} + +output "sqs_queue_arn" { + value = aws_sqs_queue.queue.arn +} + +output "sqs_queue_url" { + value = aws_sqs_queue.queue.url +} + +output "sqs_queue_read_policy_arn" { + value = aws_iam_policy.queue_read_policy.arn +} + +output "sqs_queue_write_policy_arn" { + value = aws_iam_policy.queue_write_policy.arn +} diff --git a/infra/modules/aws/_shared/sqs/variables.tf b/infra/modules/aws/_shared/sqs/variables.tf new file mode 100644 index 0000000..13661c6 --- /dev/null +++ b/infra/modules/aws/_shared/sqs/variables.tf @@ -0,0 +1,3 @@ +variable "sqs_queue_name" { + type = string +} diff --git a/infra/modules/aws/api/main.tf b/infra/modules/aws/api/main.tf index 3dd7cb9..215895d 100644 --- a/infra/modules/aws/api/main.tf +++ b/infra/modules/aws/api/main.tf @@ -1,5 +1,5 @@ module "lambda_api" { - source = "../lambda" + source = "../_shared/lambda" project_name = var.project_name environment = var.environment @@ -13,8 +13,23 @@ module "lambda_api" { } provisioned_config = { - fixed = 0 + fixed = 0 # cold starts only } + + # provisioned_config = { + # fixed = 1 # always have 1 lambda ready to go + # reserved_concurrency = 2 # only allow 2 concurrent executions THIS ALSO SERVES AS A LIMIT TO AVOID THROTTLING + # } + + # provisioned_config = { + # auto_scale = { + # max = 5 + # min = 0 + # trigger_percent = 70 + # scale_in_cooldown_seconds = 60 + # scale_out_cooldown_seconds = 60 + # } + # } } resource "aws_apigatewayv2_api" "http_api" { @@ -25,7 +40,7 @@ resource "aws_apigatewayv2_api" "http_api" { resource "aws_apigatewayv2_integration" "lambda_proxy" { api_id = aws_apigatewayv2_api.http_api.id integration_type = "AWS_PROXY" - integration_uri = module.lambda_api.arn + integration_uri = module.lambda_api.alias_arn payload_format_version = "2.0" } @@ -50,7 +65,7 @@ resource "aws_apigatewayv2_stage" "default" { resource "aws_lambda_permission" "allow_invoke" { statement_id = "AllowAPIGatewayInvoke" action = "lambda:InvokeFunction" - function_name = module.lambda_api.arn + function_name = module.lambda_api.alias_arn principal = "apigateway.amazonaws.com" source_arn = "${aws_apigatewayv2_api.http_api.execution_arn}/*/*" # all routes/stages } diff --git a/infra/modules/aws/consumer/main.tf b/infra/modules/aws/consumer/main.tf new file mode 100644 index 0000000..78cfa08 --- /dev/null +++ b/infra/modules/aws/consumer/main.tf @@ -0,0 +1,54 @@ +module "lambda_consumer" { + source = "../_shared/lambda" + + project_name = var.project_name + environment = var.environment + lambda_bucket = var.lambda_bucket + + lambda_name = "consumer" + lambda_version = var.lambda_version + + additional_policy_arns = [ + module.sqs_queue.sqs_queue_read_policy_arn + ] + + deployment_config = { + strategy = "all_at_once" + } + + provisioned_config = { + fixed = 0 # cold starts only + } + + # provisioned_config = { + # fixed = 1 # always have 1 lambda ready to go + # reserved_concurrency = 2 # only allow 2 concurrent executions THIS ALSO SERVES AS A LIMIT TO AVOID THROTTLING + # } + + # provisioned_config = { + # sqs_scale = { + # min = 1 + # max = 5 + # visible_messages = 100 + # queue_name = module.sqs_queue.sqs_queue_name + # scale_in_cooldown_seconds = 60 + # scale_out_cooldown_seconds = 60 + # } + # } +} + +module "sqs_queue" { + source = "../_shared/sqs" + + sqs_queue_name = "${var.project_name}-${var.environment}-consumer-queue" +} + +resource "aws_lambda_event_source_mapping" "sqs" { + event_source_arn = module.sqs_queue.sqs_queue_arn + function_name = module.lambda_consumer.function_name + + batch_size = 500 + maximum_batching_window_in_seconds = 10 + + function_response_types = ["ReportBatchItemFailures"] +} \ No newline at end of file diff --git a/infra/modules/aws/consumer/outputs.tf b/infra/modules/aws/consumer/outputs.tf new file mode 100644 index 0000000..e4e37c2 --- /dev/null +++ b/infra/modules/aws/consumer/outputs.tf @@ -0,0 +1,27 @@ +output "cloudwatch_log_group" { + value = module.lambda_consumer.cloudwatch_log_group +} + +output "lambda_zip_key" { + value = module.lambda_consumer.lambda_zip_key +} + +output "code_deploy_app_name" { + value = module.lambda_consumer.code_deploy_app_name +} + +output "code_deploy_group_name" { + value = module.lambda_consumer.code_deploy_group_name +} + +output "lambda_arn" { + value = module.lambda_consumer.arn +} + +output "lambda_function_name" { + value = module.lambda_consumer.function_name +} + +output "lambda_alias_name" { + value = module.lambda_consumer.alias_name +} diff --git a/infra/modules/aws/consumer/variables.tf b/infra/modules/aws/consumer/variables.tf new file mode 100644 index 0000000..da3c3a3 --- /dev/null +++ b/infra/modules/aws/consumer/variables.tf @@ -0,0 +1,24 @@ +### start of static vars set in root.hcl ### +variable "project_name" { + type = string + description = "Project name used in naming resources" +} + +variable "environment" { + type = string + description = "Environment reference used in naming resources i.e. 'dev'" +} + +variable "lambda_bucket" { + type = string + description = "Lambda bucket where the code zip(s) are uploaded to" +} +### end of static vars set in root.hcl ### + + +### start of dynamic vars required for resources ### +variable "lambda_version" { + type = string + description = "Lambda code version to be deployed. Used in locating zip file keys" +} +### end of dynamic vars required for resources ### diff --git a/infra/modules/aws/lambda/locals.tf b/infra/modules/aws/lambda/locals.tf deleted file mode 100644 index efdb97e..0000000 --- a/infra/modules/aws/lambda/locals.tf +++ /dev/null @@ -1,29 +0,0 @@ -locals { - lambda_runtime = "python3.12" - lambda_handler = "lambda_handler.lambda_handler" - - lambda_name = "${var.environment}-${var.project_name}-${var.lambda_name}" - lambda_code_zip_key = "${var.lambda_version}/${var.lambda_name}.zip" - - deploy_all_at_once_type = "AllAtOnce" - deploy_canary_type = "TimeBasedCanary" - deploy_linear_type = "TimeBasedLinear" - - deploy_config_type_map = { - all_at_once = local.deploy_all_at_once_type - canary = local.deploy_canary_type - linear = local.deploy_linear_type - } - deploy_config = { - type = local.deploy_config_type_map[var.deployment_config.strategy] - percent = var.deployment_config.percentage - minutes = var.deployment_config.interval_minutes - } - - fixed_mode = try(var.provisioned_config.fixed != null, true) - pc_fixed_count = try(var.provisioned_config.fixed, 0) - pc_min_capacity = try(var.provisioned_config.auto_scale.min, 0) - pc_max_capacity = try(var.provisioned_config.auto_scale.max, 0) - pc_trigger_percent = try(var.provisioned_config.auto_scale.trigger_percent, var.provisioned_config_defaults.trigger_percent) / 100 - pc_cool_down_seconds = try(var.provisioned_config.auto_scale.cool_down_seconds, var.provisioned_config_defaults.cool_down_seconds) -} diff --git a/infra/modules/aws/lambda/variables.tf b/infra/modules/aws/lambda/variables.tf deleted file mode 100644 index 1fc9b64..0000000 --- a/infra/modules/aws/lambda/variables.tf +++ /dev/null @@ -1,135 +0,0 @@ -### start of static vars set in root.hcl ### -variable "project_name" { - type = string - description = "Project name used in naming resources" -} - -variable "environment" { - type = string - description = "Environment reference used in naming resources i.e. 'dev'" -} - -variable "lambda_bucket" { - type = string - description = "Lambda bucket where the code zip(s) are uploaded to" -} -### end of static vars set in root.hcl ### - - -### start of dynamic vars required for resources ### -variable "lambda_name" { - type = string - description = "Must match the name of the zip - formed in /backend directory i.e. /backend/api = 'api'" -} - -variable "lambda_version" { - type = string - description = "Lambda code version to be deployed. Used in locating zip file keys" -} -### end of dynamic vars required for resources ### - - -variable "log_retention_days" { - type = number - description = "Number of days to hold logs" - default = 1 -} - -variable "deployment_config" { - description = "Traffic shifting: all_at_once | canary | linear" - type = object({ - strategy = string # all_at_once | canary | linear - percentage = optional(number) # 1..99 (req for canary/linear) - interval_minutes = optional(number) # >=1 (req for canary/linear) - }) - default = { strategy = "all_at_once" } - - validation { - condition = ( - contains(["all_at_once", "canary", "linear"], var.deployment_config.strategy) - && - ( - var.deployment_config.strategy == "all_at_once" - || - ( - coalesce(var.deployment_config.percentage, 0) >= 1 - && coalesce(var.deployment_config.percentage, 0) <= 99 - && coalesce(var.deployment_config.interval_minutes, 0) >= 1 - ) - ) - ) - error_message = "Use strategy all_at_once | canary | linear. For canary/linear, set percentage (1..99) and interval_minutes (>=1)." - } -} - -variable "provisioned_config_defaults" { - description = "Fall back values for provisioned_config.auto_scale.trigger_percent and provisioned_config.auto_scale.cool_down_seconds" - type = object({ - trigger_percent = number - cool_down_seconds = number - }) - default = { - trigger_percent = 70 - cool_down_seconds = 60 - } -} - -variable "provisioned_config" { - description = "Either fixed provisioned concurrency (fixed) or autoscaled (auto_scale); omit/zero = none" - type = object({ - fixed = optional(number) # 0/omit = off, >0 = fixed PC - auto_scale = optional(object({ - min = number - max = number - trigger_percent = optional(number) - cool_down_seconds = optional(number) - })) - }) - default = { - fixed = 0 - # auto_scale = { - # max = 1, - # min = 0, - # trigger_percent = 70 - # cool_down_seconds = 60 - # } - } - - validation { - condition = !( - (var.provisioned_config.fixed != null) && - (var.provisioned_config.auto_scale != null) - ) - error_message = "Specify either 'fixed' or 'auto_scale' (or neither), not both." - } - - # When autoscale is set, ensure max > min - validation { - condition = ( - var.provisioned_config.auto_scale != null - ? (var.provisioned_config.auto_scale.max > var.provisioned_config.auto_scale.min) - : true - ) - error_message = "When auto_scale is set, 'max' must be greater than 'min'." - } - - # When autoscale.trigger_percent is set, ensure is 1-99 - validation { - condition = ( - var.provisioned_config.auto_scale != null - ? (var.provisioned_config.auto_scale.trigger_percent > 0 && var.provisioned_config.auto_scale.trigger_percent < 100) - : true - ) - error_message = "When autoscale.trigger_percent, must be > 0 && < 100" - } - - # When autoscale.cool_down_seconds is set, ensure is at least a minute, max and hour - validation { - condition = ( - var.provisioned_config.auto_scale != null - ? (var.provisioned_config.auto_scale.cool_down_seconds > 59 && var.provisioned_config.auto_scale.cool_down_seconds < 3600) - : true - ) - error_message = "When autoscale.cool_down_seconds, must be > 59 && < 3600" - } -} \ No newline at end of file