diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4c19ff1..663a8cc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -66,7 +66,7 @@ jobs: run: just backend-build - name: Upload Lambda zips - uses: chrispsheehan/just-aws-oidc-action@0.1.1 + uses: chrispsheehan/just-aws-oidc-action@0.1.3 env: BUCKET_NAME: ${{ needs.infra.outputs.lambda_bucket_name }} with: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f900fe8..2715ceb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,9 +30,10 @@ 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: - oidc: + setup: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -44,35 +45,79 @@ jobs: aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} tg_directory: infra/live/${{ inputs.environment }}/aws/oidc - backend: - needs: - - oidc - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ inputs.infra_version }} - - - name: check Lambda vars - uses: chrispsheehan/just-aws-oidc-action@0.1.1 + - 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: - - oidc + 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 + - name: deploy api + id: deploy-api uses: chrispsheehan/terragrunt-aws-oidc-action@0.4.0 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 }} + with: + aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} + just_action: lambda-get-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 }} + with: + aws_oidc_role_arn: ${{ env.AWS_OIDC_ROLE_ARN }} + just_action: lambda-create-version + + - 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 + + - 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 diff --git a/.github/workflows/get_build.yml b/.github/workflows/get_build.yml index bb21844..1480ca2 100644 --- a/.github/workflows/get_build.yml +++ b/.github/workflows/get_build.yml @@ -53,7 +53,7 @@ jobs: echo "bucket_lambda=$(echo $TG_OUTPUTS | jq -r '.bucket_lambda.value')" >> $GITHUB_OUTPUT - name: Check Lambda version exists - uses: chrispsheehan/just-aws-oidc-action@0.1.1 + uses: chrispsheehan/just-aws-oidc-action@0.1.3 env: BUCKET_NAME: ${{ steps.get_bucket_name.outputs.bucket_lambda }} VERSION: ${{ inputs.version }} diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 9c3f991..0ac7591 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -76,19 +76,6 @@ jobs: run: terragrunt hclfmt --terragrunt-check working-directory: infra - format-frontend: - needs: check - runs-on: ubuntu-latest - if: ${{ needs.check.outputs.frontend == 'true' }} - name: Run astro formatting checks - timeout-minutes: 2 - steps: - - uses: actions/checkout@v4 - - name: Run prettier checks - run: | - npm install --prefix frontend - npm run format:check --prefix frontend - build-backend: needs: check runs-on: ubuntu-latest diff --git a/appspec.yml b/appspec.yml new file mode 100644 index 0000000..db9a9fc --- /dev/null +++ b/appspec.yml @@ -0,0 +1,9 @@ +version: 0.0 +Resources: + - LambdaFunction: + Type: AWS::Lambda::Function + Properties: + Name: ${FUNCTION_NAME} + Alias: ${FUNCTION_ALIAS} + CurrentVersion: ${CURRENT_VERSION} + TargetVersion: ${NEW_VERSION} \ No newline at end of file diff --git a/infra/live/global_vars.hcl b/infra/live/global_vars.hcl index 528669e..4baff4a 100644 --- a/infra/live/global_vars.hcl +++ b/infra/live/global_vars.hcl @@ -5,7 +5,8 @@ locals { "iam:*", "lambda:*", "logs:*", - "apigateway:*" + "apigateway:*", + "codedeploy:*" ] } diff --git a/infra/modules/aws/api/outputs.tf b/infra/modules/aws/api/outputs.tf index 67d5fa6..babcf18 100644 --- a/infra/modules/aws/api/outputs.tf +++ b/infra/modules/aws/api/outputs.tf @@ -1,3 +1,31 @@ output "invoke_url" { value = aws_apigatewayv2_api.http_api.api_endpoint } + +output "cloudwatch_log_group" { + value = module.lambda_api.cloudwatch_log_group +} + +output "lambda_zip_key" { + value = module.lambda_api.lambda_zip_key +} + +output "code_deploy_app_name" { + value = module.lambda_api.code_deploy_app_name +} + +output "code_deploy_group_name" { + value = module.lambda_api.code_deploy_group_name +} + +output "lambda_arn" { + value = module.lambda_api.arn +} + +output "lambda_function_name" { + value = module.lambda_api.function_name +} + +output "lambda_alias_name" { + value = module.lambda_api.alias_name +} diff --git a/infra/modules/aws/code_bucket/main.tf b/infra/modules/aws/code_bucket/main.tf index 01c049c..f7b85f7 100644 --- a/infra/modules/aws/code_bucket/main.tf +++ b/infra/modules/aws/code_bucket/main.tf @@ -10,3 +10,18 @@ resource "aws_s3_bucket_ownership_controls" "lambda" { object_ownership = "BucketOwnerEnforced" } } + +resource "aws_s3_bucket_lifecycle_configuration" "delete_old_files" { + count = var.s3_expiration_days > 0 ? 1 : 0 + + bucket = aws_s3_bucket.lambda.id + + rule { + id = "delete-expired-objects" + status = "Enabled" + + expiration { + days = var.s3_expiration_days + } + } +} diff --git a/infra/modules/aws/code_bucket/variables.tf b/infra/modules/aws/code_bucket/variables.tf index ab4c7c7..ff218ad 100644 --- a/infra/modules/aws/code_bucket/variables.tf +++ b/infra/modules/aws/code_bucket/variables.tf @@ -1,4 +1,13 @@ +### start of static vars set in root.hcl ### variable "lambda_bucket" { description = "S3 bucket to host lambda code files" type = string } +### end of static vars set in root.hcl ### + + +variable "s3_expiration_days" { + description = "Number of days before objects are deleted (set to 0 to disable)" + type = number + default = 0 +} diff --git a/infra/modules/aws/lambda/data.tf b/infra/modules/aws/lambda/data.tf index 688a97e..dfcb8a7 100644 --- a/infra/modules/aws/lambda/data.tf +++ b/infra/modules/aws/lambda/data.tf @@ -14,3 +14,80 @@ data "aws_iam_policy_document" "assume_role" { actions = ["sts:AssumeRole"] } } + +data "aws_iam_policy_document" "code_deploy_assume" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["codedeploy.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "codedeploy_lambda" { + statement { + sid = "LambdaControl" + effect = "Allow" + actions = [ + "lambda:GetFunction", + "lambda:PublishVersion", + "lambda:GetAlias", + "lambda:CreateAlias", + "lambda:UpdateAlias", + "lambda:ListAliases", + "lambda:ListVersionsByFunction", + ] + resources = [ + aws_lambda_function.lambda.arn, + "${aws_lambda_function.lambda.arn}:*", + ] + } + + statement { + sid = "ReadArtifactObject" + effect = "Allow" + actions = ["s3:GetObject", "s3:GetObjectVersion"] + resources = [ + "arn:aws:s3:::${data.aws_s3_bucket.lambda_code.bucket}/${var.lambda_version}/*" + ] + } + + # Allow listing the bucket for that prefix (some SDKs call this) + statement { + sid = "ListArtifactPrefix" + effect = "Allow" + actions = ["s3:ListBucket", "s3:GetBucketLocation"] + resources = ["arn:aws:s3:::${data.aws_s3_bucket.lambda_code.bucket}"] + condition { + test = "StringLike" + variable = "s3:prefix" + values = ["${var.lambda_version}/*"] + } + } + + statement { + sid = "DescribeAlarms" + effect = "Allow" + actions = ["cloudwatch:DescribeAlarms"] + resources = ["*"] + } +} + +data "aws_iam_policy_document" "lambda_iam_policy" { + statement { + sid = "AllowLambdaCloudwatchLogGroupPut" + + actions = [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + + effect = "Allow" + + resources = [ + "${aws_cloudwatch_log_group.lambda_cloudwatch_group.arn}", + "${aws_cloudwatch_log_group.lambda_cloudwatch_group.arn}:*" + ] + } +} \ No newline at end of file diff --git a/infra/modules/aws/lambda/main.tf b/infra/modules/aws/lambda/main.tf index f61d1d7..eb9434a 100644 --- a/infra/modules/aws/lambda/main.tf +++ b/infra/modules/aws/lambda/main.tf @@ -11,4 +11,80 @@ resource "aws_lambda_function" "lambda" { s3_bucket = data.aws_s3_bucket.lambda_code.bucket s3_key = local.lambda_code_zip_key -} \ No newline at end of file + + # publish ONE immutable version so we can create an alias + publish = true + + lifecycle { + # Do not update on changes to the initial s3 file version + ignore_changes = [ + s3_bucket, + s3_key, + s3_object_version, + ] + } +} + +resource "aws_cloudwatch_log_group" "lambda_cloudwatch_group" { + name = "/aws/lambda/${local.lambda_name}" + retention_in_days = var.log_retention_days +} + +resource "aws_lambda_alias" "live" { + name = var.environment + function_name = aws_lambda_function.lambda.arn + function_version = aws_lambda_function.lambda.version + + # CodeDeploy will repoint this alias → ignore drift + lifecycle { + ignore_changes = [function_version, routing_config] + } +} + +resource "aws_codedeploy_app" "app" { + name = "${local.lambda_name}-app" + compute_platform = "Lambda" +} + +resource "aws_iam_role" "code_deploy_role" { + name = "${local.lambda_name}-codedeploy-role" + assume_role_policy = data.aws_iam_policy_document.code_deploy_assume.json +} + +resource "aws_iam_role_policy" "cd_lambda" { + name = "${local.lambda_name}-codedeploy-lambda" + role = aws_iam_role.code_deploy_role.id + policy = data.aws_iam_policy_document.codedeploy_lambda.json +} + +resource "aws_codedeploy_deployment_config" "lambda_deployment_config" { + # A custom Lambda deployment config that sends 50% traffic for 1 minute, then shifts to 100% (with your DG using it and auto-rollback on failure/alarms). + deployment_config_name = "${local.lambda_name}-deployment-config" + compute_platform = "Lambda" + + traffic_routing_config { + type = "TimeBasedCanary" + time_based_canary { + percentage = 50 + interval = 1 + } + } +} + +resource "aws_codedeploy_deployment_group" "dg" { + app_name = aws_codedeploy_app.app.name + deployment_group_name = "${local.lambda_name}-dg" + service_role_arn = aws_iam_role.code_deploy_role.arn + + deployment_style { + deployment_type = "BLUE_GREEN" + deployment_option = "WITH_TRAFFIC_CONTROL" + } + + deployment_config_name = aws_codedeploy_deployment_config.lambda_deployment_config.id + + auto_rollback_configuration { + enabled = true + events = ["DEPLOYMENT_FAILURE", "DEPLOYMENT_STOP_ON_ALARM"] + } +} diff --git a/infra/modules/aws/lambda/outputs.tf b/infra/modules/aws/lambda/outputs.tf index 4fa3ebb..4fe1631 100644 --- a/infra/modules/aws/lambda/outputs.tf +++ b/infra/modules/aws/lambda/outputs.tf @@ -4,4 +4,28 @@ output "name" { output "arn" { value = aws_lambda_function.lambda.arn -} \ No newline at end of file +} + +output "function_name" { + value = aws_lambda_function.lambda.function_name +} + +output "alias_name" { + value = aws_lambda_alias.live.name +} + +output "cloudwatch_log_group" { + value = aws_cloudwatch_log_group.lambda_cloudwatch_group.name +} + +output "lambda_zip_key" { + value = local.lambda_code_zip_key +} + +output "code_deploy_app_name" { + value = aws_codedeploy_app.app.name +} + +output "code_deploy_group_name" { + value = aws_codedeploy_deployment_group.dg.deployment_group_name +} diff --git a/infra/modules/aws/lambda/variables.tf b/infra/modules/aws/lambda/variables.tf index 8fb1602..49fce64 100644 --- a/infra/modules/aws/lambda/variables.tf +++ b/infra/modules/aws/lambda/variables.tf @@ -27,3 +27,10 @@ variable "lambda_version" { 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 +} \ No newline at end of file diff --git a/justfile b/justfile index 11a3ebd..70e79c0 100644 --- a/justfile +++ b/justfile @@ -147,3 +147,86 @@ backend-build: ) echo "✅ Done: backend/$app_name.zip" done + +lambda-get-version: + #!/usr/bin/env bash + aws lambda get-alias \ + --function-name "$FUNCTION_NAME" --name "$ALIAS_NAME" \ + --query 'FunctionVersion' --output text + + +lambda-create-version: + #!/usr/bin/env bash + aws lambda update-function-code \ + --function-name "$FUNCTION_NAME" \ + --s3-bucket "$BUCKET_NAME" \ + --s3-key "$LAMBDA_ZIP_KEY" \ + --publish \ + --query 'Version' --output text + + +lambda-prepare-appspec: + #!/usr/bin/env bash + yq eval -i ' + .Resources[0].LambdaFunction.Properties.Name = env(FUNCTION_NAME) | + .Resources[0].LambdaFunction.Properties.Alias = env(ALIAS_NAME) | + .Resources[0].LambdaFunction.Properties.CurrentVersion = env(CURRENT_VERSION) | + .Resources[0].LambdaFunction.Properties.TargetVersion = env(NEW_VERSION) + ' $APP_SPEC_FILE + cat $APP_SPEC_FILE + + +lambda-upload-bundle: + #!/usr/bin/env bash + just lambda-prepare-appspec + + LOCAL_APP_SPEC_ZIP="{{justfile_directory()}}/appspec.zip" + rm -f $LOCAL_APP_SPEC_ZIP + zip -q -j $LOCAL_APP_SPEC_ZIP $APP_SPEC_FILE + aws s3 cp $LOCAL_APP_SPEC_ZIP "s3://${BUCKET_NAME}/${APP_SPEC_KEY}" + + +lambda-deploy: + #!/usr/bin/env bash + DEPLOYMENT_ID=$(aws deploy create-deployment \ + --application-name "$CODE_DEPLOY_APP_NAME" \ + --deployment-group-name "$CODE_DEPLOY_GROUP_NAME" \ + --s3-location bucket=$BUCKET_NAME,key=$APP_SPEC_KEY,bundleType=zip \ + --query "deploymentId" --output text) + + echo "🚀 Started deployment: $DEPLOYMENT_ID" + + if [[ -z "$DEPLOYMENT_ID" || "$DEPLOYMENT_ID" == "None" ]]; then + echo "❌ Failed to create deployment — no deployment ID returned." + exit 1 + fi + + MAX_ATTEMPTS=40 # ~10 minutes at 15s interval + SLEEP_INTERVAL=15 # seconds + + for ((i=1; i<=MAX_ATTEMPTS; i++)); do + STATUS=$(aws deploy get-deployment \ + --deployment-id "$DEPLOYMENT_ID" \ + --query "deploymentInfo.status" \ + --output text) + + echo "Attempt $i: Deployment status is $STATUS" + + if [[ "$STATUS" == "Succeeded" ]]; then + echo "✅ Deployment $DEPLOYMENT_ID completed successfully." + exit 0 + elif [[ "$STATUS" == "Failed" || "$STATUS" == "Stopped" ]]; then + echo "❌ Deployment $DEPLOYMENT_ID failed or was stopped." + aws deploy get-deployment \ + --deployment-id "$DEPLOYMENT_ID" \ + --query 'deploymentInfo.{Status:status, ErrorCode:errorInformation.code, ErrorMessage:errorInformation.message}' \ + --output table + exit 1 + fi + + sleep "$SLEEP_INTERVAL" + done + + echo "❌ Deployment $DEPLOYMENT_ID did not complete within expected time." + exit 1 +