From a9b8c670e4ce201302d99125e241816bd7187521 Mon Sep 17 00:00:00 2001 From: mianava Date: Sat, 25 Apr 2026 00:45:06 -0400 Subject: [PATCH 01/30] Build alarm to slack function from source code. Manage logs. Update tags. --- terraform/services/alarm-to-slack/README.md | 75 ++++++++++++++++++- terraform/services/alarm-to-slack/main.tf | 29 ++++++- .../services/alarm-to-slack/terraform.tf | 18 ----- terraform/services/alarm-to-slack/tofu.tf | 30 ++++++++ .../services/alarm-to-slack/variables.tf | 5 ++ 5 files changed, 134 insertions(+), 23 deletions(-) delete mode 100644 terraform/services/alarm-to-slack/terraform.tf create mode 100644 terraform/services/alarm-to-slack/tofu.tf diff --git a/terraform/services/alarm-to-slack/README.md b/terraform/services/alarm-to-slack/README.md index 4309e4d9..fa626beb 100644 --- a/terraform/services/alarm-to-slack/README.md +++ b/terraform/services/alarm-to-slack/README.md @@ -1,6 +1,6 @@ # OpenTofu for alarm-to-slack function and associated infra -This service sets up the infrastructure for the alarm-to-slack lambda function in upper and lower environments for DPC +This service sets up the infrastructure for the alarm-to-slack lambda function in upper and lower environments for all applications in var.apps_served. ## Updating the lambda code @@ -20,3 +20,76 @@ AWS_REGION=us-east-1 tofu apply ## Automated deploy This terraform is automatically applied on merge to main by the tf-alarm-to-slack-apply.yml workflow. + + + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | 5.100.0 | + + +## Requirements + +No requirements. + + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [app](#input\_app) | The application name (bcda, cdap) | `string` | n/a | yes | +| [env](#input\_env) | The application environment (test, prod) | `string` | n/a | yes | +| [apps\_served](#input\_apps\_served) | n/a | `list(string)` |
[
"bcda",
"cdap",
"dpc"
]
| no | + + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [sns\_to\_slack\_function](#module\_sns\_to\_slack\_function) | ../../modules/function | n/a | +| [sns\_to\_slack\_queue](#module\_sns\_to\_slack\_queue) | github.com/CMSgov/cdap/terraform/modules/queue | b177921621c97d02dc4a21f830e4532147aa0749 | +| [standards](#module\_standards) | ../../modules/standards | n/a | + + +## Resources + +| Name | Type | +|------|------| +| [aws_iam_policy_document.sqs_queue_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_ssm_parameters_by_path.slack_webhook_urls](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ssm_parameters_by_path) | data source | + + +## Outputs + +| Name | Description | +|------|-------------| +| [function\_role\_arn](#output\_function\_role\_arn) | n/a | +| [sqs\_queue\_arn](#output\_sqs\_queue\_arn) | n/a | +| [zip\_bucket](#output\_zip\_bucket) | n/a | + \ No newline at end of file diff --git a/terraform/services/alarm-to-slack/main.tf b/terraform/services/alarm-to-slack/main.tf index 9ce9d454..f53c1060 100644 --- a/terraform/services/alarm-to-slack/main.tf +++ b/terraform/services/alarm-to-slack/main.tf @@ -2,10 +2,18 @@ locals { full_name = "${var.app}-${var.env}-alarm-to-slack" } -data "aws_caller_identity" "current" {} +import { + to = module.sns_to_slack_function.aws_cloudwatch_log_group.function + id = "/aws/lambda/${local.full_name}" +} + +data "aws_ssm_parameters_by_path" "slack_webhook_urls" { + for_each = toset(var.apps_served) + path = "/${each.value}/lambda/slack_webhook_url" +} module "sns_to_slack_function" { - source = "github.com/CMSgov/cdap/terraform/modules/function?ref=2874c72ccd4c4821e5e3f77ccf61cf77ed05169f" + source = "../../modules/function" app = var.app env = var.env @@ -14,11 +22,24 @@ module "sns_to_slack_function" { name = local.full_name description = "Listens for CloudWatch Alerts and forwards to Slack" - # TODO use zip file + ssm_parameter_paths = flatten([ + for app, data in data.aws_ssm_parameters_by_path.slack_webhook_urls : + data.arns + ]) handler = "lambda_function.lambda_handler" runtime = "python3.13" + # Point to the local source directory — module handles zip + upload + source_dir = "${path.module}/lambda_src" + + # Optionally exclude tests and cache + source_dir_excludes = [ + "__pycache__", + "test_*.py", + "*.pyc", + ] + environment_variables = { IGNORE_OK = true } @@ -60,7 +81,7 @@ data "aws_iam_policy_document" "sqs_queue_policy" { variable = "aws:SourceArn" values = [ - "arn:aws:sns:us-east-1:${data.aws_caller_identity.current.account_id}:*" + "arn:aws:sns:us-east-1:${module.standards.account_id}:*" ] } } diff --git a/terraform/services/alarm-to-slack/terraform.tf b/terraform/services/alarm-to-slack/terraform.tf deleted file mode 100644 index 4ae8cd4d..00000000 --- a/terraform/services/alarm-to-slack/terraform.tf +++ /dev/null @@ -1,18 +0,0 @@ -provider "aws" { - default_tags { - tags = { - application = var.app - business = "oeda" - code = "https://github.com/CMSgov/cdap/tree/main/terraform/services/alarm-to-slack" - component = "alarm-to-slack" - environment = var.env - terraform = true - } - } -} - -terraform { - backend "s3" { - key = "alarm-to-slack/terraform.tfstate" - } -} diff --git a/terraform/services/alarm-to-slack/tofu.tf b/terraform/services/alarm-to-slack/tofu.tf new file mode 100644 index 00000000..881e5a4b --- /dev/null +++ b/terraform/services/alarm-to-slack/tofu.tf @@ -0,0 +1,30 @@ +terraform { + backend "s3" { + key = "alarm-to-slack/terraform.tfstate" + } +} + +provider "aws" { + region = "us-east-1" + default_tags { + tags = module.standards.default_tags + } +} + +provider "aws" { + alias = "secondary" + region = "us-west-2" + default_tags { + tags = module.standards.default_tags + } +} + +module "standards" { + source = "../../modules/standards" + providers = { aws = aws, aws.secondary = aws.secondary } + + app = var.app + env = var.env + root_module = "https://github.com/CMSgov/cdap/tree/main/terraform/services/${basename(abspath(path.module))}/" + service = replace(basename(abspath(path.module)), "/^[0-9]+-/", "") +} diff --git a/terraform/services/alarm-to-slack/variables.tf b/terraform/services/alarm-to-slack/variables.tf index c2220adf..428b3af8 100644 --- a/terraform/services/alarm-to-slack/variables.tf +++ b/terraform/services/alarm-to-slack/variables.tf @@ -15,3 +15,8 @@ variable "env" { error_message = "Valid values for env are test, prod." } } + +variable "apps_served" { + type = list(string) + default = ["bcda", "cdap", "dpc"] +} From 9e2d3da9aa7d83d1ba69cbe1eaf0608ac2b3c396 Mon Sep 17 00:00:00 2001 From: mianava Date: Sat, 25 Apr 2026 00:45:29 -0400 Subject: [PATCH 02/30] Enable source code updates via Tofu. Manage cloudwatch logs. Refine IAM. --- terraform/modules/function/README.md | 114 ++++++++++++- terraform/modules/function/iam.tf | 157 ++++++++++++++++++ terraform/modules/function/main.tf | 207 +++++------------------- terraform/modules/function/variables.tf | 99 ++++++++++-- 4 files changed, 391 insertions(+), 186 deletions(-) create mode 100644 terraform/modules/function/iam.tf diff --git a/terraform/modules/function/README.md b/terraform/modules/function/README.md index 2ab524b2..900ef657 100644 --- a/terraform/modules/function/README.md +++ b/terraform/modules/function/README.md @@ -2,4 +2,116 @@ This is a generic module for creating lambda function resources in CMS Cloud. Use it in terraform services where a lambda function is needed. -Note that a dummy function is included to allow for initialization. It is meant to be replaced once the function has been created. +Note that a dummy function is included to allow for initialization without defined source code. It is meant to be replaced once the function has been created. +Function logic can be deployed separately via GitHub actions or can be updated by re-applying Terraform with source_dir set. + + + +## Providers + +| Name | Version | +|------|---------| +| [archive](#provider\_archive) | n/a | +| [aws](#provider\_aws) | n/a | + + +## Requirements + +No requirements. + + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [app](#input\_app) | The application name (ab2d, bcda, cdap dpc) | `string` | n/a | yes | +| [description](#input\_description) | Description of the lambda function | `string` | n/a | yes | +| [env](#input\_env) | The application environment (dev, test, sandbox, prod) | `string` | n/a | yes | +| [name](#input\_name) | Name of the lambda function | `string` | n/a | yes | +| [architecture](#input\_architecture) | Lambda function CPU architecture. Use arm64 for Graviton (better price/performance for most workloads). | `string` | `"x86_64"` | no | +| [create\_function\_zip](#input\_create\_function\_zip) | Upload a dummy zip to initialize the S3 bucket on first apply.
Has no effect and should not be set to true when source\_dir is provided,
as the module will manage the zip and upload automatically. | `bool` | `false` | no | +| [environment\_variables](#input\_environment\_variables) | Map of environment variables for the function | `map(string)` | `{}` | no | +| [extra\_kms\_key\_arns](#input\_extra\_kms\_key\_arns) | Optional list of additional KMS key ARNs the Lambda can use | `list(string)` | `[]` | no | +| [function\_role\_inline\_policies](#input\_function\_role\_inline\_policies) | Inline policies (in JSON) for the function IAM role | `map(string)` | `{}` | no | +| [github\_actions\_repos](#input\_github\_actions\_repos) | Used for integration tests and, when source\_dir is null,
for CI/CD workflows that upload the function zip.
Format: "repo:CMSgov/:*" or a more specific ref pattern.
Defaults to empty — no GitHub Actions access unless explicitly granted. | `list(string)` | `[]` | no | +| [handler](#input\_handler) | Lambda function handler | `string` | `"function_handler"` | no | +| [layer\_arns](#input\_layer\_arns) | Optional list of layer arns | `list(string)` | `[]` | no | +| [log\_retention\_days](#input\_log\_retention\_days) | Number of days to retain Lambda function logs in CloudWatch. If null, no retention policy is set and retention is managed externally (e.g., via cdap/scripts/set\_log\_retention/). | `number` | `180` | no | +| [memory\_size](#input\_memory\_size) | Lambda function memory size | `number` | `null` | no | +| [runtime](#input\_runtime) | Lambda function runtime | `string` | `"python3.11"` | no | +| [schedule\_expression](#input\_schedule\_expression) | Cron or rate expression for a scheduled function | `string` | `""` | no | +| [source\_code\_version](#input\_source\_code\_version) | Optional S3 object version of function.zip uploaded to module's zip\_bucket by external sources. | `string` | `null` | no | +| [source\_dir](#input\_source\_dir) | Path to the Lambda source directory to zip and upload. If set, the module manages zipping and deployment. If null, an external process (or dummy zip) is used. | `string` | `null` | no | +| [source\_dir\_excludes](#input\_source\_dir\_excludes) | List of glob (**/*) patterns to exclude when zipping the source directory. | `list(string)` | `[]` | no | +| [ssm\_parameter\_paths](#input\_ssm\_parameter\_paths) | List of SSM parameter ARNs or path patterns this function is permitted to read.
Each entry should be a full ARN or ARN pattern. This can be retrieved from platform.module.ssm.ssm\_root\_name.parameter\_name.arn.
If empty (default), the function receives no SSM access.
Do not use broad wildcards — scope each entry to the specific parameters this function requires. | `list(string)` | `[]` | no | +| [timeout](#input\_timeout) | Lambda function timeout | `number` | `900` | no | + + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [subnets](#module\_subnets) | ../subnets | n/a | +| [vpc](#module\_vpc) | ../vpc | n/a | +| [zip\_bucket](#module\_zip\_bucket) | ../bucket | n/a | + + +## Resources + +| Name | Type | +|------|------| +| [aws_cloudwatch_event_rule.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | +| [aws_cloudwatch_event_target.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | +| [aws_cloudwatch_log_group.function](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_iam_role.function](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy.default_function](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.extra_policies](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_lambda_function.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | +| [aws_lambda_permission.cloudwatch_events](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | +| [aws_s3_object.empty_function_zip](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_object) | resource | +| [aws_s3_object.function_zip](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_object) | resource | +| [aws_security_group.function](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [archive_file.function](https://registry.terraform.io/providers/hashicorp/archive/latest/docs/data-sources/file) | data source | +| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | +| [aws_iam_openid_connect_provider.github](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_openid_connect_provider) | data source | +| [aws_iam_policy_document.cicd_manage_lambda_objects](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.default_function](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.function_assume_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_role.admin](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_role) | data source | +| [aws_iam_role.dasg_admin](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_role) | data source | +| [aws_kms_alias.kms_key](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/kms_alias) | data source | + + +## Outputs + +| Name | Description | +|------|-------------| +| [name](#output\_name) | Name for the lambda function | +| [role\_arn](#output\_role\_arn) | ARN of the IAM role for the function | +| [security\_group\_id](#output\_security\_group\_id) | ID for the security group for the function | +| [zip\_bucket](#output\_zip\_bucket) | Bucket name for the function.zip file | + \ No newline at end of file diff --git a/terraform/modules/function/iam.tf b/terraform/modules/function/iam.tf new file mode 100644 index 00000000..7a9b1324 --- /dev/null +++ b/terraform/modules/function/iam.tf @@ -0,0 +1,157 @@ +data "aws_iam_openid_connect_provider" "github" { + url = "https://${local.provider_domain}" +} + +data "aws_iam_role" "admin" { + name = "ct-ado-bcda-application-admin" +} + +data "aws_iam_role" "dasg_admin" { + name = "ct-ado-dasg-application-admin" +} + +data "aws_iam_policy_document" "function_assume_role" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + } + + # Allow access from GitHub-hosted runners via OIDC for integration tests + dynamic "statement" { + for_each = length(var.github_actions_repos) > 0 ? [1] : [] + content { + actions = ["sts:AssumeRoleWithWebIdentity", "sts:TagSession"] + principals { + type = "Federated" + identifiers = [data.aws_iam_openid_connect_provider.github.arn] + } + condition { + test = "StringEquals" + variable = "${local.provider_domain}:aud" + values = ["sts.amazonaws.com"] + } + condition { + test = "StringLike" + variable = "${local.provider_domain}:sub" + values = var.github_actions_repos + } + } + } + + # Allow access from admin role for manual checks + statement { + actions = [ + "sts:AssumeRole", + "sts:TagSession", + ] + + principals { + type = "AWS" + identifiers = [data.aws_iam_role.admin.arn, data.aws_iam_role.dasg_admin.arn] + } + } +} + +data "aws_caller_identity" "current" {} + +data "aws_iam_policy_document" "default_function" { + dynamic "statement" { + for_each = length(var.ssm_parameter_paths) > 0 ? [1] : [] + content { + sid = "SSMParameterRead" + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + ] + resources = var.ssm_parameter_paths + } + } + + statement { + sid = "VPCNetworkingENI" + actions = [ + "ec2:CreateNetworkInterface", + "ec2:DeleteNetworkInterface", + "ec2:DescribeAccountAttributes", + "ec2:DescribeNetworkInterfaces", + ] + resources = ["*"] + } + + statement { + sid = "CloudWatchLogsWrite" + actions = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + ] + resources = ["${aws_cloudwatch_log_group.function.arn}:*"] + } + + statement { + sid = "KMSKeyAccess" + actions = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:GenerateDataKey", + ] + resources = concat( + [data.aws_kms_alias.kms_key.target_key_arn], + var.extra_kms_key_arns + ) + } +} + +resource "aws_iam_role" "function" { + name = "${var.name}-function" + path = "/delegatedadmin/developer/" + + permissions_boundary = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/cms-cloud-admin/developer-boundary-policy" + + assume_role_policy = data.aws_iam_policy_document.function_assume_role.json +} + +resource "aws_iam_role_policy" "default_function" { + name = "default-function" + role = aws_iam_role.function.id + policy = data.aws_iam_policy_document.default_function.json +} + +resource "aws_iam_role_policy" "extra_policies" { + for_each = var.function_role_inline_policies + + name = each.key + role = aws_iam_role.function.id + policy = each.value +} + +# Allow CICD management outside of Tofu runs +data "aws_iam_policy_document" "cicd_manage_lambda_objects" { + statement { + actions = [ + "s3:GetObject", + "s3:GetObjectTagging", + "s3:GetObjectVersion", + "s3:GetObjectVersionTagging", + "s3:ListBucket", + "s3:PutObject", + "s3:PutObjectTagging" + ] + + principals { + type = "AWS" + identifiers = [ + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role" + ] + } + + resources = [ + module.zip_bucket.arn, + "${module.zip_bucket.arn}/*", + ] + } +} + diff --git a/terraform/modules/function/main.tf b/terraform/modules/function/main.tf index 4a380452..e6848a19 100644 --- a/terraform/modules/function/main.tf +++ b/terraform/modules/function/main.tf @@ -1,187 +1,48 @@ locals { provider_domain = "token.actions.githubusercontent.com" - - repos = { - bcda = [ - "repo:CMSgov/bcda-app:*", - ] - cdap = [ - "repo:CMSgov/cdap:*", - ] - dpc = [ - "repo:CMSgov/dpc-app:*", - ] - } } data "aws_kms_alias" "kms_key" { name = "alias/${var.app}-${var.env}" } -data "aws_iam_openid_connect_provider" "github" { - url = "https://${local.provider_domain}" -} - -data "aws_iam_role" "admin" { - name = "ct-ado-bcda-application-admin" -} - -data "aws_iam_role" "dasg_admin" { - name = "ct-ado-dasg-application-admin" -} - -data "aws_iam_policy_document" "function_assume_role" { - statement { - actions = ["sts:AssumeRole"] - - principals { - type = "Service" - identifiers = ["lambda.amazonaws.com"] - } - } - - # Allow access from GitHub-hosted runners via OIDC for integration tests - statement { - actions = [ - "sts:AssumeRoleWithWebIdentity", - "sts:TagSession", - ] - - principals { - type = "Federated" - identifiers = [data.aws_iam_openid_connect_provider.github.arn] - } - - condition { - test = "StringEquals" - variable = "${local.provider_domain}:aud" - values = ["sts.amazonaws.com"] - } - - condition { - test = "StringLike" - variable = "${local.provider_domain}:sub" - values = local.repos[var.app] - } - } - - # Allow access from admin role for manual checks - statement { - actions = [ - "sts:AssumeRole", - "sts:TagSession", - ] - - principals { - type = "AWS" - identifiers = [data.aws_iam_role.admin.arn, data.aws_iam_role.dasg_admin.arn] - } - } -} - -data "aws_caller_identity" "current" {} - -data "aws_iam_policy_document" "default_function" { - statement { - actions = [ - "ec2:CreateNetworkInterface", - "ec2:DeleteNetworkInterface", - "ec2:DescribeAccountAttributes", - "ec2:DescribeNetworkInterfaces", - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:PutLogEvents", - "sqs:DeleteMessage", - "sqs:GetQueueAttributes", - "sqs:ReceiveMessage", - "ssm:GetParameter", - "ssm:GetParameters", - ] - resources = ["*"] - } - - statement { - actions = [ - "kms:Encrypt", - "kms:Decrypt", - "kms:GenerateDataKey" - ] - resources = concat( - [data.aws_kms_alias.kms_key.target_key_arn], - var.extra_kms_key_arns - ) - } -} - -resource "aws_iam_role" "function" { - name = "${var.name}-function" - path = "/delegatedadmin/developer/" - - permissions_boundary = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/cms-cloud-admin/developer-boundary-policy" - - assume_role_policy = data.aws_iam_policy_document.function_assume_role.json -} - -resource "aws_iam_role_policy" "default_function" { - name = "default-function" - role = aws_iam_role.function.id - policy = data.aws_iam_policy_document.default_function.json -} - -resource "aws_iam_role_policy" "extra_policies" { - for_each = var.function_role_inline_policies +# Only used when source_dir is provided +data "archive_file" "function" { + count = var.source_dir != null ? 1 : 0 - name = each.key - role = aws_iam_role.function.id - policy = each.value -} - -data "aws_ssm_parameter" "prod_account_id" { - count = var.env == "test" ? 1 : 0 - name = "/prod/account-id" -} - -data "aws_iam_policy_document" "bucket_cross_account_read_roles_policy" { - count = var.env == "test" ? 1 : 0 - - statement { - actions = [ - "s3:GetObject", - "s3:GetObjectTagging", - "s3:GetObjectVersion", - "s3:GetObjectVersionTagging", - "s3:ListBucket", - ] - - principals { - type = "AWS" - identifiers = [ - "arn:aws:iam::${data.aws_ssm_parameter.prod_account_id[0].value}:role/delegatedadmin/developer/${var.app}-prod-github-actions", - "arn:aws:iam::${data.aws_ssm_parameter.prod_account_id[0].value}:role/delegatedadmin/developer/${var.app}-${var.app == "cdap" ? "mgmt" : "sandbox"}-github-actions", - ] - } - - resources = [ - module.zip_bucket.arn, - "${module.zip_bucket.arn}/*", - ] - - sid = "CrossAccountRead" - } + type = "zip" + source_dir = var.source_dir + output_path = "${path.module}/.terraform/tmp/${var.name}-function.zip" + excludes = var.source_dir_excludes } module "zip_bucket" { source = "../bucket" - additional_bucket_policies = var.env == "test" ? [data.aws_iam_policy_document.bucket_cross_account_read_roles_policy[0].json] : [] + additional_bucket_policies = var.source_dir == null ? [data.aws_iam_policy_document.cicd_manage_lambda_objects.json] : [] app = var.app env = var.env name = "${var.name}-function" ssm_parameter = "/${var.app}/${var.env}/${var.name}-bucket" } +# Managed zip upload — used when source_dir is provided +resource "aws_s3_object" "function_zip" { + count = var.source_dir != null ? 1 : 0 + + bucket = module.zip_bucket.id + key = "function.zip" + source = data.archive_file.function[0].output_path + + # Use the hash so S3 object (and Lambda) updates when source changes + source_hash = data.archive_file.function[0].output_base64sha256 + + # KMS encryption + kms_key_id = data.aws_kms_alias.kms_key.target_key_arn +} + resource "aws_s3_object" "empty_function_zip" { - count = var.create_function_zip ? 1 : 0 + count = var.source_dir == null && var.create_function_zip ? 1 : 0 bucket = module.zip_bucket.id key = "function.zip" @@ -221,11 +82,13 @@ resource "aws_security_group" "function" { } resource "aws_lambda_function" "this" { - description = var.description - function_name = var.name - s3_key = "function.zip" - s3_bucket = module.zip_bucket.id - s3_object_version = var.source_code_version + description = var.description + function_name = var.name + s3_key = "function.zip" + s3_bucket = module.zip_bucket.id + # If source_dir is managed by this module, track the uploaded object version. + # Otherwise, fall back to the externally-supplied version (or null). + s3_object_version = var.source_dir != null ? aws_s3_object.function_zip[0].version_id : var.source_code_version kms_key_arn = data.aws_kms_alias.kms_key.target_key_arn role = aws_iam_role.function.arn handler = var.handler @@ -273,3 +136,11 @@ resource "aws_lambda_permission" "cloudwatch_events" { principal = "events.amazonaws.com" source_arn = aws_cloudwatch_event_rule.this[0].arn } + +# Manage cloudwatch log group to ensure compliant +resource "aws_cloudwatch_log_group" "function" { + name = "/aws/lambda/${var.name}" + kms_key_id = data.aws_kms_alias.kms_key.target_key_arn + skip_destroy = true # Log group persists after tofu destroy + retention_in_days = var.log_retention_days +} diff --git a/terraform/modules/function/variables.tf b/terraform/modules/function/variables.tf index c8a2006e..cd6d8cd2 100644 --- a/terraform/modules/function/variables.tf +++ b/terraform/modules/function/variables.tf @@ -26,6 +26,8 @@ variable "description" { type = string } +# ── Core Function Config + variable "handler" { description = "Lambda function handler" type = string @@ -33,7 +35,7 @@ variable "handler" { } variable "architecture" { - description = "" + description = "Lambda function CPU architecture. Use arm64 for Graviton (better price/performance for most workloads)." type = string default = "x86_64" validation { @@ -60,22 +62,46 @@ variable "memory_size" { default = null } -variable "function_role_inline_policies" { - description = "Inline policies (in JSON) for the function IAM role" - type = map(string) - default = {} +# ── Source / Deployment ─────────────────────────────────────────────────────── + +variable "source_dir" { + description = "Path to the Lambda source directory to zip and upload. If set, the module manages zipping and deployment. If null, an external process (or dummy zip) is used." + type = string + default = null } -variable "environment_variables" { - description = "Map of environment variables for the function" - type = map(string) - default = {} +variable "source_dir_excludes" { + description = "List of glob (**/*) patterns to exclude when zipping the source directory." + type = list(string) + default = [] +} + +variable "source_code_version" { + description = "Optional S3 object version of function.zip uploaded to module's zip_bucket by external sources." + type = string + default = null } variable "create_function_zip" { - description = "Create the function zip file, necessary for initialization (defaults to true)" + description = <<-EOT + Upload a dummy zip to initialize the S3 bucket on first apply. + Has no effect and should not be set to true when source_dir is provided, + as the module will manage the zip and upload automatically. + EOT type = bool - default = true + default = false + validation { + condition = !(var.create_function_zip && var.source_dir != null) + error_message = "create_function_zip must not be true when source_dir is provided. The module manages the zip automatically." + } +} + +# ── Runtime Behavior ────────────────────────────────────────────────────────── + +variable "environment_variables" { + description = "Map of environment variables for the function" + type = map(string) + default = {} } variable "schedule_expression" { @@ -84,20 +110,59 @@ variable "schedule_expression" { default = "" } -variable "extra_kms_key_arns" { +variable "log_retention_days" { + description = "Number of days to retain Lambda function logs in CloudWatch. If null, no retention policy is set and retention is managed externally (e.g., via cdap/scripts/set_log_retention/)." + type = number + default = 180 +} + +# ── IAM / Permissions ───────────────────────────────────────────────────────── + +variable "ssm_parameter_paths" { + description = <<-EOT + List of SSM parameter ARNs or path patterns this function is permitted to read. + Each entry should be a full ARN or ARN pattern. This can be retrieved from platform.module.ssm.ssm_root_name.parameter_name.arn. + If empty (default), the function receives no SSM access. + Do not use broad wildcards — scope each entry to the specific parameters this function requires. + EOT type = list(string) default = [] + validation { + condition = alltrue([ + for arn in var.ssm_parameter_paths : + can(regex("^arn:aws:ssm:", arn)) + ]) + error_message = "Each entry in ssm_parameter_paths must be a valid SSM parameter ARN starting with 'arn:aws:ssm:'." + } +} + +variable "function_role_inline_policies" { + description = "Inline policies (in JSON) for the function IAM role" + type = map(string) + default = {} +} + +# ── Advanced / Migration strategies ───────────────────────────────────────────────── + +variable "extra_kms_key_arns" { description = "Optional list of additional KMS key ARNs the Lambda can use" + type = list(string) + default = [] } variable "layer_arns" { + description = "Optional list of layer arns" type = list(string) default = [] - description = "Optional list of layer arns" } -variable "source_code_version" { - description = "Optional S3 object version of function.zip uploaded to module's zip_bucket." - type = string - default = null +variable "github_actions_repos" { + description = <<-EOT + Used for integration tests and, when source_dir is null, + for CI/CD workflows that upload the function zip. + Format: "repo:CMSgov/:*" or a more specific ref pattern. + Defaults to empty — no GitHub Actions access unless explicitly granted. + EOT + type = list(string) + default = [] } From ca1aed8a4ead66d950a3bc8bc779b02bb383294a Mon Sep 17 00:00:00 2001 From: mianava Date: Sat, 25 Apr 2026 01:27:58 -0400 Subject: [PATCH 03/30] Manage sqs iam access --- terraform/services/alarm-to-slack/iam.tf | 40 +++++++++++++++++ terraform/services/alarm-to-slack/main.tf | 44 ++++--------------- .../services/alarm-to-slack/variables.tf | 5 ++- 3 files changed, 52 insertions(+), 37 deletions(-) create mode 100644 terraform/services/alarm-to-slack/iam.tf diff --git a/terraform/services/alarm-to-slack/iam.tf b/terraform/services/alarm-to-slack/iam.tf new file mode 100644 index 00000000..0bf7f8c2 --- /dev/null +++ b/terraform/services/alarm-to-slack/iam.tf @@ -0,0 +1,40 @@ +data "aws_iam_policy_document" "sqs_trigger" { + statement { + sid = "SQSTriggerReceive" + actions = [ + "sqs:DeleteMessage", + "sqs:GetQueueAttributes", + "sqs:ReceiveMessage", + ] + resources = [module.sns_to_slack_queue.arn] + } +} + +data "aws_iam_policy_document" "sqs_queue_policy" { + statement { + sid = "allow_sns_access" + effect = "Allow" + + principals { + type = "Service" + identifiers = ["sns.amazonaws.com"] + } + + actions = [ + "SQS:SendMessage", + ] + + resources = [ + module.sns_to_slack_queue.arn + ] + + condition { + test = "ArnLike" + variable = "aws:SourceArn" + + values = [ + "arn:aws:sns:us-east-1:${module.standards.account_id}:*" + ] + } + } +} diff --git a/terraform/services/alarm-to-slack/main.tf b/terraform/services/alarm-to-slack/main.tf index f53c1060..3064abf6 100644 --- a/terraform/services/alarm-to-slack/main.tf +++ b/terraform/services/alarm-to-slack/main.tf @@ -14,21 +14,24 @@ data "aws_ssm_parameters_by_path" "slack_webhook_urls" { module "sns_to_slack_function" { source = "../../modules/function" - - app = var.app - env = var.env - architecture = "arm64" + app = var.app + env = var.env name = local.full_name description = "Listens for CloudWatch Alerts and forwards to Slack" + architecture = "arm64" + handler = "lambda_function.lambda_handler" + runtime = "python3.13" + ssm_parameter_paths = flatten([ for app, data in data.aws_ssm_parameters_by_path.slack_webhook_urls : data.arns ]) - handler = "lambda_function.lambda_handler" - runtime = "python3.13" + function_role_inline_policies = { + sqs-trigger = data.aws_iam_policy_document.sqs_trigger.json + } # Point to the local source directory — module handles zip + upload source_dir = "${path.module}/lambda_src" @@ -57,32 +60,3 @@ module "sns_to_slack_queue" { data.aws_iam_policy_document.sqs_queue_policy.json ] } - -data "aws_iam_policy_document" "sqs_queue_policy" { - statement { - sid = "allow_sns_access" - effect = "Allow" - - principals { - type = "Service" - identifiers = ["sns.amazonaws.com"] - } - - actions = [ - "SQS:SendMessage", - ] - - resources = [ - module.sns_to_slack_queue.arn - ] - - condition { - test = "ArnLike" - variable = "aws:SourceArn" - - values = [ - "arn:aws:sns:us-east-1:${module.standards.account_id}:*" - ] - } - } -} diff --git a/terraform/services/alarm-to-slack/variables.tf b/terraform/services/alarm-to-slack/variables.tf index 428b3af8..70bb3c76 100644 --- a/terraform/services/alarm-to-slack/variables.tf +++ b/terraform/services/alarm-to-slack/variables.tf @@ -17,6 +17,7 @@ variable "env" { } variable "apps_served" { - type = list(string) - default = ["bcda", "cdap", "dpc"] + description = "List of app names whose Slack webhook URLs this function reads from SSM at runtime." + type = list(string) + default = ["bcda", "cdap", "dpc"] } From d24e52dc827f15189bbb333949edc1d80a66e82f Mon Sep 17 00:00:00 2001 From: mianava Date: Sat, 25 Apr 2026 01:28:30 -0400 Subject: [PATCH 04/30] Allow CICD only when source is not provided. --- terraform/modules/function/iam.tf | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/terraform/modules/function/iam.tf b/terraform/modules/function/iam.tf index 7a9b1324..55d08c3d 100644 --- a/terraform/modules/function/iam.tf +++ b/terraform/modules/function/iam.tf @@ -131,6 +131,7 @@ resource "aws_iam_role_policy" "extra_policies" { # Allow CICD management outside of Tofu runs data "aws_iam_policy_document" "cicd_manage_lambda_objects" { statement { + sid = "CICDZipUpload" actions = [ "s3:GetObject", "s3:GetObjectTagging", @@ -138,13 +139,13 @@ data "aws_iam_policy_document" "cicd_manage_lambda_objects" { "s3:GetObjectVersionTagging", "s3:ListBucket", "s3:PutObject", - "s3:PutObjectTagging" + "s3:PutObjectTagging", ] principals { type = "AWS" identifiers = [ - "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role" + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/delegatedadmin/developer/${var.app}-${var.env}-github-actions", ] } @@ -154,4 +155,3 @@ data "aws_iam_policy_document" "cicd_manage_lambda_objects" { ] } } - From 171171470a43a98bd1de6f8d62a96cff21936431 Mon Sep 17 00:00:00 2001 From: mianava Date: Sat, 25 Apr 2026 01:42:20 -0400 Subject: [PATCH 05/30] Remove unusable permission for an assumed role --- terraform/modules/function/iam.tf | 1 - 1 file changed, 1 deletion(-) diff --git a/terraform/modules/function/iam.tf b/terraform/modules/function/iam.tf index 55d08c3d..0de3f555 100644 --- a/terraform/modules/function/iam.tf +++ b/terraform/modules/function/iam.tf @@ -46,7 +46,6 @@ data "aws_iam_policy_document" "function_assume_role" { statement { actions = [ "sts:AssumeRole", - "sts:TagSession", ] principals { From 0cdc7754f6febb614b364f7d0a3b4eb6066257c4 Mon Sep 17 00:00:00 2001 From: mianava Date: Sat, 25 Apr 2026 02:16:51 -0400 Subject: [PATCH 06/30] Enable log tag management from github --- terraform/services/github-actions-role/main.tf | 1 + 1 file changed, 1 insertion(+) diff --git a/terraform/services/github-actions-role/main.tf b/terraform/services/github-actions-role/main.tf index 3ebbe950..72313c73 100644 --- a/terraform/services/github-actions-role/main.tf +++ b/terraform/services/github-actions-role/main.tf @@ -398,6 +398,7 @@ data "aws_iam_policy_document" "github_actions_policy" { "logs:DescribeLogGroups", "logs:DescribeLogStreams", "logs:DescribeSubscriptionFilters", + "logs:ListTagsForResource", "logs:PutRetentionPolicy" ] resources = ["*"] From 3bc98370633293e481f00e894b135031da4346be Mon Sep 17 00:00:00 2001 From: mianava Date: Sat, 25 Apr 2026 02:38:59 -0400 Subject: [PATCH 07/30] Create dummy function if no other configuration is available. --- terraform/modules/function/README.md | 7 ++++--- terraform/modules/function/main.tf | 9 ++------- terraform/modules/function/variables.tf | 14 -------------- 3 files changed, 6 insertions(+), 24 deletions(-) diff --git a/terraform/modules/function/README.md b/terraform/modules/function/README.md index 900ef657..aa25794b 100644 --- a/terraform/modules/function/README.md +++ b/terraform/modules/function/README.md @@ -2,8 +2,9 @@ This is a generic module for creating lambda function resources in CMS Cloud. Use it in terraform services where a lambda function is needed. -Note that a dummy function is included to allow for initialization without defined source code. It is meant to be replaced once the function has been created. -Function logic can be deployed separately via GitHub actions or can be updated by re-applying Terraform with source_dir set. +Note that a dummy function will be made if source_dir with function logic is not yet provided or github_actions_repo is not defined. +The dummy function allows for infrastructure scaffolding before source code is written. +If source code is written and the lifecycle is managed outside of terraform, set github_actions_repo. \ No newline at end of file + diff --git a/terraform/modules/function/main.tf b/terraform/modules/function/main.tf index e6848a19..507f68e9 100644 --- a/terraform/modules/function/main.tf +++ b/terraform/modules/function/main.tf @@ -19,7 +19,7 @@ data "archive_file" "function" { module "zip_bucket" { source = "../bucket" - additional_bucket_policies = var.source_dir == null ? [data.aws_iam_policy_document.cicd_manage_lambda_objects.json] : [] + additional_bucket_policies = length(var.github_actions_repos) > 0 ? [data.aws_iam_policy_document.cicd_manage_lambda_objects.json] : [] app = var.app env = var.env name = "${var.name}-function" @@ -42,16 +42,11 @@ resource "aws_s3_object" "function_zip" { } resource "aws_s3_object" "empty_function_zip" { - count = var.source_dir == null && var.create_function_zip ? 1 : 0 + count = var.source_dir == null && length(var.github_actions_repos) == 0 ? 1 : 0 bucket = module.zip_bucket.id key = "function.zip" source = "${path.module}/dummy_function.zip" - - # This resource only exists to initialize the function, not manage it - lifecycle { - ignore_changes = all - } } module "vpc" { diff --git a/terraform/modules/function/variables.tf b/terraform/modules/function/variables.tf index cd6d8cd2..efd39ec1 100644 --- a/terraform/modules/function/variables.tf +++ b/terraform/modules/function/variables.tf @@ -82,20 +82,6 @@ variable "source_code_version" { default = null } -variable "create_function_zip" { - description = <<-EOT - Upload a dummy zip to initialize the S3 bucket on first apply. - Has no effect and should not be set to true when source_dir is provided, - as the module will manage the zip and upload automatically. - EOT - type = bool - default = false - validation { - condition = !(var.create_function_zip && var.source_dir != null) - error_message = "create_function_zip must not be true when source_dir is provided. The module manages the zip automatically." - } -} - # ── Runtime Behavior ────────────────────────────────────────────────────────── variable "environment_variables" { From b85fc091a541c47c55b0de948c5b850acef6fe28 Mon Sep 17 00:00:00 2001 From: mianava Date: Mon, 27 Apr 2026 10:18:00 -0400 Subject: [PATCH 08/30] Use security group rule instead of inline so security gorup rules can be easily used elsewhere. --- terraform/modules/function/main.tf | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/terraform/modules/function/main.tf b/terraform/modules/function/main.tf index 507f68e9..85060d4c 100644 --- a/terraform/modules/function/main.tf +++ b/terraform/modules/function/main.tf @@ -66,14 +66,17 @@ resource "aws_security_group" "function" { name = "${var.name}-function" description = "For the ${var.name} function" vpc_id = module.vpc.id +} - egress { - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - ipv6_cidr_blocks = ["::/0"] - } +# FixMe Refine egress +resource "aws_security_group_rule" "egress" { + type = "egress" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = var.allowed_cidr_blocks ? var.allowed_cidr_blocks : [module.vpc.cidr_block] + ipv6_cidr_blocks = ["::/0"] + security_group_id = aws_security_group.function.id } resource "aws_lambda_function" "this" { From 9dbe1b63bf3c8ebd2581061407d1d3f7a50d50c0 Mon Sep 17 00:00:00 2001 From: mianava Date: Mon, 27 Apr 2026 12:14:22 -0400 Subject: [PATCH 09/30] Ensure security groups can be refined --- terraform/modules/function/main.tf | 47 ++++++++++++++++++++----- terraform/modules/function/variables.tf | 32 +++++++++++++++++ 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/terraform/modules/function/main.tf b/terraform/modules/function/main.tf index 85060d4c..9e475770 100644 --- a/terraform/modules/function/main.tf +++ b/terraform/modules/function/main.tf @@ -68,15 +68,46 @@ resource "aws_security_group" "function" { vpc_id = module.vpc.id } -# FixMe Refine egress -resource "aws_security_group_rule" "egress" { - type = "egress" - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = var.allowed_cidr_blocks ? var.allowed_cidr_blocks : [module.vpc.cidr_block] - ipv6_cidr_blocks = ["::/0"] +resource "aws_vpc_security_group_egress_rule" "ipv4" { + for_each = { + for idx, rule in var.egress_rules : tostring(idx) => rule + if rule.cidr_ipv4 != null + } + security_group_id = aws_security_group.function.id + cidr_ipv4 = each.value.cidr_ipv4 + ip_protocol = each.value.protocol + from_port = each.value.from_port + to_port = each.value.to_port + description = each.value.description +} + +resource "aws_vpc_security_group_egress_rule" "ipv6" { + for_each = { + for idx, rule in var.egress_rules : tostring(idx) => rule + if rule.cidr_ipv6 != null + } + + security_group_id = aws_security_group.function.id + cidr_ipv6 = each.value.cidr_ipv6 + ip_protocol = each.value.protocol + from_port = each.value.from_port + to_port = each.value.to_port + description = each.value.description +} + +resource "aws_vpc_security_group_egress_rule" "sg_source" { + for_each = { + for idx, rule in var.egress_rules : tostring(idx) => rule + if rule.referenced_sg_id != null + } + + security_group_id = aws_security_group.function.id + referenced_security_group_id = each.value.referenced_sg_id + ip_protocol = each.value.protocol + from_port = each.value.from_port + to_port = each.value.to_port + description = each.value.description } resource "aws_lambda_function" "this" { diff --git a/terraform/modules/function/variables.tf b/terraform/modules/function/variables.tf index efd39ec1..db1ddc68 100644 --- a/terraform/modules/function/variables.tf +++ b/terraform/modules/function/variables.tf @@ -152,3 +152,35 @@ variable "github_actions_repos" { type = list(string) default = [] } + +variable "egress_rules" { + description = "List of egress rules to apply to the security group" + type = list(object({ + name = string + from_port = number + to_port = number + protocol = string + cidr_ipv4 = optional(string) + cidr_ipv6 = optional(string) + referenced_sg_id = optional(string) + description = optional(string) + })) + default = [ + { + name = "allow-all-ipv4" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_ipv4 = "0.0.0.0/0" + description = "Allow all egress traffic (IPv4) - migration default" + }, + { + name = "allow-all-ipv6" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_ipv6 = "::/0" + description = "Allow all egress traffic (IPv6) - migration default" + } + ] +} From b25b0c7ae833187be320a0d495504e7b8d618035 Mon Sep 17 00:00:00 2001 From: mianava Date: Mon, 27 Apr 2026 12:15:53 -0400 Subject: [PATCH 10/30] Only force log retention in prod --- terraform/modules/function/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terraform/modules/function/main.tf b/terraform/modules/function/main.tf index 9e475770..64357f7b 100644 --- a/terraform/modules/function/main.tf +++ b/terraform/modules/function/main.tf @@ -170,6 +170,6 @@ resource "aws_lambda_permission" "cloudwatch_events" { resource "aws_cloudwatch_log_group" "function" { name = "/aws/lambda/${var.name}" kms_key_id = data.aws_kms_alias.kms_key.target_key_arn - skip_destroy = true # Log group persists after tofu destroy + skip_destroy = strcontains(var.env, "prod") ? true : false retention_in_days = var.log_retention_days } From 72fd839614525f72627a924441e7f18a7fd30062 Mon Sep 17 00:00:00 2001 From: mianava Date: Mon, 4 May 2026 21:11:21 -0400 Subject: [PATCH 11/30] Add a liveness check --- terraform/modules/function/main.tf | 14 +++ terraform/modules/function/variables.tf | 29 +++++ .../lambda_src/lambda_function.py | 116 +++++++++++++++++- terraform/services/alarm-to-slack/main.tf | 1 + .../services/tftesting/function/README.md | 0 5 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 terraform/services/tftesting/function/README.md diff --git a/terraform/modules/function/main.tf b/terraform/modules/function/main.tf index 64357f7b..b22bd657 100644 --- a/terraform/modules/function/main.tf +++ b/terraform/modules/function/main.tf @@ -173,3 +173,17 @@ resource "aws_cloudwatch_log_group" "function" { skip_destroy = strcontains(var.env, "prod") ? true : false retention_in_days = var.log_retention_days } + +resource "aws_lambda_invocation" "liveness_check" { + count = liveness_check_enabled ? 1 : 0 + function_name = aws_lambda_function.this.function_name + + # Re-runs whenever the Lambda source code changes + triggers = { + redeployment = aws_lambda_function.this.source_code_hash + } + + input = jsonencode({ + RequestType = "LivenessCheck" + }) +} diff --git a/terraform/modules/function/variables.tf b/terraform/modules/function/variables.tf index db1ddc68..b07d42f3 100644 --- a/terraform/modules/function/variables.tf +++ b/terraform/modules/function/variables.tf @@ -82,6 +82,35 @@ variable "source_code_version" { default = null } +variable "liveness_check_enabled" { + description = <<-EOT + Enables a deploy-time liveness check that invokes the Lambda function + immediately after deployment to verify it is healthy and correctly configured. + + When enabled, an aws_lambda_invocation resource is created that sends a + { "RequestType": "LivenessCheck" } payload to the Lambda function after + each deployment. The invocation is re-triggered whenever the Lambda source + code changes (tracked via source_code_hash). + + The Lambda function is responsible for implementing the liveness check logic + in its handler. This may include verifying external dependencies, validating + configuration, checking connectivity to downstream services, or any other + health validation relevant to the function's purpose. + + If the liveness check fails, the Lambda should raise an exception. This + surfaces as a function error and causes the Tofu apply to fail, alerting + the deploying team immediately. + + See the liveness_check_handler_key variable for the expected event shape, + and refer to your function's documentation for what the liveness check + validates. + + Recommended: true in all environments to catch misconfiguration at deploy time. + EOT + type = bool + default = true +} + # ── Runtime Behavior ────────────────────────────────────────────────────────── variable "environment_variables" { diff --git a/terraform/services/alarm-to-slack/lambda_src/lambda_function.py b/terraform/services/alarm-to-slack/lambda_src/lambda_function.py index 4453eb9c..105fc7e6 100644 --- a/terraform/services/alarm-to-slack/lambda_src/lambda_function.py +++ b/terraform/services/alarm-to-slack/lambda_src/lambda_function.py @@ -44,12 +44,126 @@ def is_ignore_ok(): """ return os.environ.get('IGNORE_OK', 'false').lower() == 'true' +def ping_slack_webhook(webhook, app, message_id=None): + """ + Sends a liveness ping to a Slack webhook using Slack's no-op endpoint pattern. + Uses an empty payload to trigger a validation response from Slack without + posting a visible message. Returns True if Slack responds with 200, False otherwise. + + Note: Slack returns 400 for empty/invalid payloads, but a 400 still confirms + the webhook URL is reachable and valid. A URLError or non-reachable host + indicates a broken webhook. + """ + try: + # Send minimal JSON — Slack will return 400 "no_text" but the URL is reachable + jsondata = json.dumps({}).encode('utf-8') + req = request.Request(webhook) + req.add_header('Content-Type', 'application/json; charset=utf-8') + req.add_header('Content-Length', str(len(jsondata))) + with request.urlopen(req, jsondata) as resp: + log({'msg': f'Liveness ping succeeded for app: {app}', 'status': resp.status, + 'messageId': message_id}) + return True + except URLError as e: + # Slack returns HTTP 400 for empty payloads, which raises URLError in urllib. + # A 400 still means the webhook URL is reachable — treat it as alive. + reason = str(e.reason) + if hasattr(e, 'code') and e.code == 400: + log({'msg': f'Liveness ping reachable (400 expected) for app: {app}', + 'messageId': message_id}) + return True + log({'msg': f'Liveness ping FAILED for app: {app}, reason: {reason}', + 'messageId': message_id}) + return False + +def liveness_check(): + """ + Iterates over all configured apps (from the APPS env var), retrieves each + app's Slack webhook SSM parameter, and performs a connectivity ping. + + Returns a dict with: + - 'results': per-app status (ssm_ok, webhook_reachable) + - 'all_ok': True only if every app passed both checks + """ + apps = get_app_list() + if not apps: + log({'msg': 'Liveness check: No apps configured in APPS environment variable'}) + return {'results': {}, 'all_ok': True} + + results = {} + all_ok = True + + for app in apps: + param_name = f'/{app}/lambda/slack_webhook_url' + webhook = get_ssm_parameter(param_name) + + ssm_ok = webhook is not None + webhook_reachable = False + + if ssm_ok: + webhook_reachable = ping_slack_webhook(webhook, app) + else: + log({'msg': f'Liveness check FAILED: SSM parameter missing or broken for app: {app}', + 'param': param_name}) + + app_ok = ssm_ok and webhook_reachable + all_ok = all_ok and app_ok + + results[app] = { + 'ssm_ok': ssm_ok, + 'webhook_reachable': webhook_reachable, + 'ok': app_ok, + } + + log({ + 'msg': 'Liveness check result', + 'app': app, + 'ssm_ok': ssm_ok, + 'webhook_reachable': webhook_reachable, + 'ok': app_ok, + }) + + return {'results': results, 'all_ok': all_ok} + +def handle_liveness_event(event): + """ + Handles a deploy-time liveness check invocation from Tofu's aws_lambda_invocation. + Raises RuntimeError if any app's SSM parameter or Slack webhook is unreachable, + which surfaces as a function error and fails the Tofu apply. + """ + check = liveness_check() + + log({ + 'msg': 'Liveness check complete', + 'all_ok': check['all_ok'], + 'results': check['results'], + }) + + if not check['all_ok']: + failed = [app for app, r in check['results'].items() if not r['ok']] + raise RuntimeError( + f"Liveness check failed for app(s): {', '.join(failed)}. " + "Check CloudWatch logs for details." + ) + + return { + 'statusCode': 200, + 'body': 'Liveness check passed', + 'results': check['results'], + } + def lambda_handler(event, _): """ Main entry point for the Lambda function. - It iterates through the SQS records, processes each CloudWatch alarm, + Handles two event types: + 1) A liveness check that can be invoked via Tofu changes + 2) Primary function: Iteration through the SQS records, processes each CloudWatch alarm, and forwards it to the appropriate Slack channel. """ + + if event.get('RequestType') == 'LivenessCheck': + return handle_liveness_event(event) + processed_count = 0 for record in event['Records']: message = enriched_cloudwatch_message(record) diff --git a/terraform/services/alarm-to-slack/main.tf b/terraform/services/alarm-to-slack/main.tf index 3064abf6..b43e1821 100644 --- a/terraform/services/alarm-to-slack/main.tf +++ b/terraform/services/alarm-to-slack/main.tf @@ -45,6 +45,7 @@ module "sns_to_slack_function" { environment_variables = { IGNORE_OK = true + APPS = "bcda, dpc, ab2d" } } diff --git a/terraform/services/tftesting/function/README.md b/terraform/services/tftesting/function/README.md new file mode 100644 index 00000000..e69de29b From ab0a1e45396dab1461cc2251a91c0edb038a2324 Mon Sep 17 00:00:00 2001 From: mianava Date: Mon, 4 May 2026 21:11:37 -0400 Subject: [PATCH 12/30] Set up place for test function --- terraform/services/tftesting/function/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/terraform/services/tftesting/function/README.md b/terraform/services/tftesting/function/README.md index e69de29b..6ec21fb7 100644 --- a/terraform/services/tftesting/function/README.md +++ b/terraform/services/tftesting/function/README.md @@ -0,0 +1,3 @@ +# Lambda function module +This allows manual testing of the module at terraform/modules/function. +This will provision a basic lambda, from the source code in ./lambda_src/ and verify liveness using the invocation in the function module. From 6df91f7be6bbb4a8dd6aa506d8491a021ca14306 Mon Sep 17 00:00:00 2001 From: keeyanghoreshi-rgb Date: Mon, 4 May 2026 11:09:17 -0400 Subject: [PATCH 13/30] AB2D-7227 update to java25 (#460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🎫 Ticket https://jira.cms.gov/browse/AB2D-7227 ## 🛠 Changes Bumps the version of the aws provider to v6, and alters one field deprecated in the new version. ## ℹ️ Context AB2D is upgrading Java to version 25, and the aws provider v5 only supports up to version 21. This change keeps the version of the aws provider aligned and prevents mismatch errors on AB2Ds side. This change will support upgrades to java in this PR: https://github.com/CMSgov/ab2d/pull/1769 ## 🧪 Validation Updated platform ref used in the AB2D lambdas, works as expected and can find Java25. --- terraform/modules/platform/main.tf | 2 +- terraform/modules/platform/terraform.tf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/terraform/modules/platform/main.tf b/terraform/modules/platform/main.tf index e0978f89..16831837 100644 --- a/terraform/modules/platform/main.tf +++ b/terraform/modules/platform/main.tf @@ -115,7 +115,7 @@ data "aws_s3_bucket" "access_logs" { } data "aws_s3_bucket" "logs_to_splunk" { - bucket = "cms-cloud-${data.aws_caller_identity.this.account_id}-${data.aws_region.primary.name}" + bucket = "cms-cloud-${data.aws_caller_identity.this.account_id}-${data.aws_region.primary.region}" } data "aws_security_groups" "this" { diff --git a/terraform/modules/platform/terraform.tf b/terraform/modules/platform/terraform.tf index bd633a74..0885f73d 100644 --- a/terraform/modules/platform/terraform.tf +++ b/terraform/modules/platform/terraform.tf @@ -2,7 +2,7 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = "~>5" + version = "~>6" configuration_aliases = [aws.secondary] } } From cf406cc02b8fc4f8edf1755c27daf26b753ebbd2 Mon Sep 17 00:00:00 2001 From: Luke Short Date: Mon, 4 May 2026 09:03:27 -0700 Subject: [PATCH 14/30] Ls/dpc 5387 quicksight lambda updates (#459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🎫 Ticket DPC-5387 ## 🛠 Changes - define new _optional_ input variable for bucket module's encryption key - default behavior remains exactly the same ## ℹ️ Context - For DPC's we have a use case where we want to explicitly specify a key for s3 to use for encrypting files. - For quicksight reporting, the same bucket is used by - 1. postgres extension(s) to output data - Example query `create_job()` query [HERE](https://github.com/CMSgov/dpc-app/blob/main/scripts/schedule_view_exports.sql#L43) - 2. python lambda to process data into a report - Previous dpc-ops changes relied on a free-floating feature branch from this repository specified [HERE](https://github.com/CMSgov/dpc-ops/blob/f19985817a3165bd2e227914b2c557ef7575849d/terraform/lambda/quicksight-reports/main.tf#L79) - This PR is intended to be merged before further dpc-ops changes to avoid code becoming brittle based on a separate branch ## 🧪 Validation Tested in dpc `test` environment. See Validation section in dpc-ops PR: https://github.com/CMSgov/dpc-ops/pull/947 --------- Co-authored-by: Julian Scott --- terraform/modules/bucket/main.tf | 13 +++++++++---- terraform/modules/bucket/variables.tf | 6 ++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/terraform/modules/bucket/main.tf b/terraform/modules/bucket/main.tf index 866a4caf..f7627f6e 100644 --- a/terraform/modules/bucket/main.tf +++ b/terraform/modules/bucket/main.tf @@ -19,8 +19,9 @@ resource "aws_s3_bucket_versioning" "this" { } } -data "aws_kms_alias" "kms_key" { - name = "alias/${var.app}-${var.env}" +data "aws_kms_alias" "default_encryption_key" { + count = var.kms_key_arn == null ? 1 : 0 + name = "alias/${var.app}-${var.env}" } data "aws_iam_policy_document" "ssl_only" { @@ -68,8 +69,12 @@ resource "aws_s3_bucket_server_side_encryption_configuration" "this" { bucket_key_enabled = true apply_server_side_encryption_by_default { - kms_master_key_id = data.aws_kms_alias.kms_key.target_key_arn - sse_algorithm = "aws:kms" + sse_algorithm = "aws:kms" + kms_master_key_id = ( + var.kms_key_arn == null ? + data.aws_kms_alias.default_encryption_key[0].target_key_arn : + var.kms_key_arn + ) } } } diff --git a/terraform/modules/bucket/variables.tf b/terraform/modules/bucket/variables.tf index f40602dc..a91bcaef 100644 --- a/terraform/modules/bucket/variables.tf +++ b/terraform/modules/bucket/variables.tf @@ -13,6 +13,12 @@ variable "app" { } } +variable "kms_key_arn" { + default = null + description = "The ARN of the default S3 bucket encryption key" + type = string +} + variable "env" { description = "The application environment (dev, test, sandbox, prod, mgmt)" type = string From 1961e95c8c55f40cba81e7725f2ab85564eed7a4 Mon Sep 17 00:00:00 2001 From: Parwinder Bhagat Date: Tue, 5 May 2026 13:03:04 -0500 Subject: [PATCH 15/30] BCDA-9967: add model short name (#461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🎫 Ticket https://jira.cms.gov/browse/BCDA-9967 ## 🛠 Changes Adding `model_name` to the production and sandbox jobs view ## ℹ️ Context We want to have the Alternative Payment Model included in job request data so that it can be easily aggregated, without having to derive the model from each entity's CMS ID ## 🧪 Validation Executed queries against prod/sandbox locally --- .../views/bcda/prod-jobs-with-cms-id.view.sql | 14 ++++++++++++++ .../bcda/sandbox-jobs-with-cms-id.view.sql | 17 +++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/terraform/services/insights/views/bcda/prod-jobs-with-cms-id.view.sql b/terraform/services/insights/views/bcda/prod-jobs-with-cms-id.view.sql index 32026e28..bc95254a 100644 --- a/terraform/services/insights/views/bcda/prod-jobs-with-cms-id.view.sql +++ b/terraform/services/insights/views/bcda/prod-jobs-with-cms-id.view.sql @@ -11,6 +11,20 @@ SELECT jobs.transaction_time, jobs.benes_attributed_to_aco, acos.cms_id, + CASE + WHEN acos.cms_id ~ 'D\d{4}' THEN 'ACO REACH' + WHEN acos.cms_id ~ 'K\d{4}' THEN 'KCC' + WHEN acos.cms_id ~ 'C\d{4}' THEN 'KCC' + WHEN acos.cms_id ~ 'CT\d{6}' THEN 'MD TCoC' + WHEN acos.cms_id ~ '^A\d{4}' THEN 'SSP' + WHEN acos.cms_id ~ 'IOTA\d{3}' THEN 'IOTA' + WHEN acos.cms_id ~ 'GUIDE-\d{5}' THEN 'GUIDE' + WHEN acos.cms_id ~ 'DA\d{4}' THEN 'CDAC' + WHEN acos.cms_id ~ 'TEST\d{3}' THEN 'TEST' + WHEN acos.cms_id ~ 'V\d{3}' THEN 'NGACO' + WHEN acos.cms_id ~ 'E\d{4}' THEN 'CEC' + ELSE 'Unknown' + END AS model_name, sq.benes_with_data, sq.benes_retrieved_percent FROM jobs diff --git a/terraform/services/insights/views/bcda/sandbox-jobs-with-cms-id.view.sql b/terraform/services/insights/views/bcda/sandbox-jobs-with-cms-id.view.sql index cd9cff5d..7db0ff47 100644 --- a/terraform/services/insights/views/bcda/sandbox-jobs-with-cms-id.view.sql +++ b/terraform/services/insights/views/bcda/sandbox-jobs-with-cms-id.view.sql @@ -12,6 +12,23 @@ SELECT jobs.transaction_time, jobs.benes_attributed_to_aco, acos.cms_id, + CASE + WHEN acos.cms_id ~ 'D\d{4}' THEN 'ACO REACH' + WHEN acos.cms_id ~ 'K\d{4}' THEN 'KCC' + WHEN acos.cms_id ~ 'C\d{4}' THEN 'KCC' + WHEN acos.cms_id ~ 'CT\d{6}' THEN 'MD TCoC' + WHEN acos.cms_id ~ '^A\d{4}' THEN 'SSP' + WHEN acos.cms_id ~ 'IOTA\d{3}' THEN 'IOTA' + WHEN acos.cms_id ~ 'GUIDE-\d{5}' THEN 'GUIDE' + WHEN acos.cms_id ~ 'DA\d{4}' THEN 'CDAC' + WHEN acos.cms_id ~ 'TEST\d{3}' THEN 'TEST' + WHEN acos.cms_id ~ 'V\d{3}' THEN 'NGACO' + WHEN acos.cms_id ~ 'E\d{4}' THEN 'CEC' + WHEN acos.cms_id ~ 'SBXA\w\d{3}' THEN 'Sandbox Adj' + WHEN acos.cms_id ~ 'SBXP\w\d{3}' THEN 'Sandbox Partially-Adj' + WHEN acos.cms_id ~ 'SBXB\w\d{3}' THEN 'Sandbox Both' + ELSE 'Unknown' + END AS model_name, sq.benes_with_data, sq.benes_retrieved_percent FROM jobs From a7596e8fd9ab3e600c0915846a880a41b270995a Mon Sep 17 00:00:00 2001 From: Parwinder Bhagat Date: Tue, 5 May 2026 13:36:10 -0500 Subject: [PATCH 16/30] BCDA-9967: update column order to match existing view (#462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🎫 Ticket https://jira.cms.gov/browse/BCDA-9967 ## 🛠 Changes Updated column order to match the existing view ## ℹ️ Context Postgres rigidly maintains the existing column order for the existing view unless we drop and recreate the view. The earlier PR merged (#461) yields error: ``` SQL Error [42P16]: ERROR: cannot change name of view column "cms_id" to "benes_attributed_to_aco" Hint: Use ALTER VIEW ... RENAME COLUMN ... to change name of view column instead. ``` ## 🧪 Validation Executed/Updated views (without errors) and checked retrieved data --- .../views/bcda/prod-jobs-with-cms-id.view.sql | 8 ++++---- .../views/bcda/sandbox-jobs-with-cms-id.view.sql | 11 +++++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/terraform/services/insights/views/bcda/prod-jobs-with-cms-id.view.sql b/terraform/services/insights/views/bcda/prod-jobs-with-cms-id.view.sql index bc95254a..42196cc6 100644 --- a/terraform/services/insights/views/bcda/prod-jobs-with-cms-id.view.sql +++ b/terraform/services/insights/views/bcda/prod-jobs-with-cms-id.view.sql @@ -9,8 +9,10 @@ SELECT jobs.updated_at, jobs.job_count, jobs.transaction_time, - jobs.benes_attributed_to_aco, acos.cms_id, + jobs.benes_attributed_to_aco, + sq.benes_with_data, + sq.benes_retrieved_percent, CASE WHEN acos.cms_id ~ 'D\d{4}' THEN 'ACO REACH' WHEN acos.cms_id ~ 'K\d{4}' THEN 'KCC' @@ -24,9 +26,7 @@ SELECT WHEN acos.cms_id ~ 'V\d{3}' THEN 'NGACO' WHEN acos.cms_id ~ 'E\d{4}' THEN 'CEC' ELSE 'Unknown' - END AS model_name, - sq.benes_with_data, - sq.benes_retrieved_percent + END AS model_name FROM jobs LEFT JOIN acos ON acos.uuid = jobs.aco_id JOIN ( diff --git a/terraform/services/insights/views/bcda/sandbox-jobs-with-cms-id.view.sql b/terraform/services/insights/views/bcda/sandbox-jobs-with-cms-id.view.sql index 7db0ff47..28d80312 100644 --- a/terraform/services/insights/views/bcda/sandbox-jobs-with-cms-id.view.sql +++ b/terraform/services/insights/views/bcda/sandbox-jobs-with-cms-id.view.sql @@ -10,8 +10,10 @@ SELECT jobs.updated_at, jobs.job_count, jobs.transaction_time, - jobs.benes_attributed_to_aco, acos.cms_id, + jobs.benes_attributed_to_aco, + sq.benes_with_data, + sq.benes_retrieved_percent, CASE WHEN acos.cms_id ~ 'D\d{4}' THEN 'ACO REACH' WHEN acos.cms_id ~ 'K\d{4}' THEN 'KCC' @@ -28,14 +30,11 @@ SELECT WHEN acos.cms_id ~ 'SBXP\w\d{3}' THEN 'Sandbox Partially-Adj' WHEN acos.cms_id ~ 'SBXB\w\d{3}' THEN 'Sandbox Both' ELSE 'Unknown' - END AS model_name, - sq.benes_with_data, - sq.benes_retrieved_percent + END AS model_name FROM jobs LEFT JOIN acos ON acos.uuid = jobs.aco_id JOIN ( SELECT job_id, SUM(benes_with_data) AS benes_with_data, AVG(benes_retrieved_percent) AS benes_retrieved_percent FROM job_keys GROUP BY job_id ) AS sq -ON sq.job_id = jobs.id - +ON sq.job_id = jobs.id \ No newline at end of file From 89fda667447f219d19bf364a357ad7c5ca3a1b49 Mon Sep 17 00:00:00 2001 From: mianava Date: Tue, 5 May 2026 15:53:49 -0400 Subject: [PATCH 17/30] PLT-1661 Snyk and sonarqube checks on python logic (#453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🎫 Ticket PLT-1661 ## 🛠 Changes Migrates python checks to leverage sonarqube and snyk for linting and security validation. Enables a common workflow that is then called by relevant python check workflows on python paths in scripts/ or in terraform paths with /lambda_src/. Sets up a snyk token for usage in a CDAP specific organization in CMS snyk. ## ℹ️ Context These changes were made to use standard code scanning tools provided by CMS. ## 🧪 Validation The workflow can be validated once merged. The python-checks script was run locally which works for snyk scanning and configuring sonar, but does not test full evaluation for sonar as the project cannot be created with the common user credentials. --- .github/workflows/alarm-to-slack-checks.yml | 18 ++++ .github/workflows/python-checks-reusable.yml | 93 +++++++++++++++++++ .github/workflows/python-checks.yml | 22 ----- .../workflows/set_log_retention_checks.yml | 18 ++++ scripts/python-checks | 18 ---- .../010-config/values/prod.sopsw.yaml | 1 + .../010-config/values/test.sopsw.yaml | 1 + 7 files changed, 131 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/alarm-to-slack-checks.yml create mode 100644 .github/workflows/python-checks-reusable.yml delete mode 100644 .github/workflows/python-checks.yml create mode 100644 .github/workflows/set_log_retention_checks.yml delete mode 100755 scripts/python-checks diff --git a/.github/workflows/alarm-to-slack-checks.yml b/.github/workflows/alarm-to-slack-checks.yml new file mode 100644 index 00000000..582e1e2b --- /dev/null +++ b/.github/workflows/alarm-to-slack-checks.yml @@ -0,0 +1,18 @@ +name: python-checks (alarm-to-slack) +description: Checks CDAP hosted alarm-to-slack function for python vulnerabilities and code quality. + +on: + workflow_dispatch: + pull_request: + paths: + - 'terraform/services/alarm-to-slack/lambda_src/**/*.py' + - 'terraform/services/alarm-to-slack/lambda_src/**/requirements.txt' + - 'terraform/modules/function/**' + +jobs: + python-checks: + uses: ./.github/workflows/python-checks-reusable.yml + with: + source_path: terraform/services/alarm-to-slack/lambda_src + sonar_project_key: cdap-alarm-to-slack + sonar_project_name: "CDAP Alarm to Slack" diff --git a/.github/workflows/python-checks-reusable.yml b/.github/workflows/python-checks-reusable.yml new file mode 100644 index 00000000..4f8b93d0 --- /dev/null +++ b/.github/workflows/python-checks-reusable.yml @@ -0,0 +1,93 @@ +name: python-checks (reusable) +description: Workflow that can be called to evaluate any script or folder containing python logic. + +on: + workflow_call: + inputs: + source_path: + description: 'Path to the Python source directory to scan' + required: true + type: string + python_version: + description: 'Python version to use' + required: false + type: string + default: '3.14' + sonar_project_key: + description: 'SonarQube project key' + required: true + type: string + sonar_project_name: + description: 'SonarQube project name' + required: true + type: string + +jobs: + python-checks: + permissions: + contents: read + id-token: write + runs-on: codebuild-cdap-${{ github.ref_name == 'main' && 'prod' || 'non-prod' }}-${{ github.run_id }}-${{ github.run_attempt }} + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.3.0 + with: + fetch-depth: 0 + + - name: Set up Python ${{ inputs.python_version }} + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: ${{ inputs.python_version }} + update-environment: true + + - name: Assume role to AWS + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v4.1.0 + with: + role-to-assume: arn:aws:iam::${{ github.ref_name == 'main' && secrets.PROD_ACCOUNT || secrets.NON_PROD_ACCOUNT }}:role/delegatedadmin/developer/${{ github.ref_name == 'main' && 'cdap-prod' || 'cdap-test' }}-github-actions + aws-region: ${{ vars.AWS_REGION }} + + - name: Retrieve credentials + uses: ./actions/aws-params-env-action + env: + AWS_REGION: ${{ vars.AWS_REGION }} + with: + params: | + SNYK_TOKEN=/cdap/${{ github.ref_name == 'main' && 'prod' || 'test' }}/snyk/api_token + SONAR_HOST_URL=/sonarqube/url + SONAR_TOKEN=/sonarqube/token + + - name: Install Snyk CLI + run: | + curl -sSLo /usr/local/bin/snyk https://static.snyk.io/cli/latest/snyk-linux-arm64 + chmod +x /usr/local/bin/snyk + snyk --version + + - name: Snyk Dependency Scan (snyk test) + run: | + REQ="${{ inputs.source_path }}/requirements.txt" + if [ -f "$REQ" ]; then + echo "[Snyk] Found requirements.txt — running dependency scan..." + snyk test --file="$REQ" + else + echo "[Snyk] No requirements.txt found at $REQ — skipping dependency scan." + fi + + - name: Snyk Code Scan (snyk code test) + continue-on-error: true + run: snyk code test ${{ inputs.source_path }} --include="**/*.py" + + - name: Run SonarQube Scan + uses: SonarSource/sonarqube-scan-action@299e4b793aaa83bf2aba7c9c14bedbb485688ec4 # v7.1.0 + with: + args: > + -Dsonar.projectKey=${{ inputs.sonar_project_key }} + -Dsonar.projectName=${{ inputs.sonar_project_name }} + -Dsonar.sources=${{ inputs.source_path }} + -Dsonar.language=py + -Dsonar.python.version=${{ inputs.python_version }} + -Dsonar.exclusions=**/__pycache__/**,**/*.pyc,**/.terraform/** + + - name: Quality Gate Check + uses: sonarsource/sonarqube-quality-gate-action@cf038b0e0cdecfa9e56c198bbb7d21d751d62c3b # v1.2.0 + timeout-minutes: 5 diff --git a/.github/workflows/python-checks.yml b/.github/workflows/python-checks.yml deleted file mode 100644 index 0f4ace5b..00000000 --- a/.github/workflows/python-checks.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: python-checks - -on: - workflow_dispatch: - pull_request: - paths: - - 'terraform/services/alarm-to-slack/lambda_src/**' - -jobs: - python-checks: - runs-on: codebuild-cdap-${{ github.ref_name =='main' && 'prod' || 'non-prod' }}-${{github.run_id}}-${{github.run_attempt}} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.3.0 - - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v 47.0.5 - id: changed-dirs - with: - files: | - terraform/services/alarm-to-slack/lambda_src/** - dir_names: 'true' - - run: scripts/python-checks - env: - CHANGED_DIRS: ${{ steps.changed-dirs.outputs.all_changed_files }} diff --git a/.github/workflows/set_log_retention_checks.yml b/.github/workflows/set_log_retention_checks.yml new file mode 100644 index 00000000..2339c08b --- /dev/null +++ b/.github/workflows/set_log_retention_checks.yml @@ -0,0 +1,18 @@ +name: set-log-retention-checks (cloudwatch log retention) +description: Checks CDAP hosted check-log-retention for python vulnerabilities and code quality. + +on: + workflow_dispatch: + pull_request: + paths: + - 'scripts/set_log_retention/*.py' + - 'scripts/set_log_retention/requirements.txt' + - 'terraform/modules/function/**' + +jobs: + python-checks: + uses: ./.github/workflows/python-checks-reusable.yml + with: + source_path: scripts/set_log_retention + sonar_project_key: cdap-set-log-retention + sonar_project_name: "CDAP Set Log Retention" diff --git a/scripts/python-checks b/scripts/python-checks deleted file mode 100755 index a8959e92..00000000 --- a/scripts/python-checks +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -# Run checks on python directories. Used in the python-checks workflow. -set -e - -repo_root="$(git rev-parse --show-toplevel)" -for dir in $CHANGED_DIRS; do - dir_absolute="$repo_root/$dir" - [ ! -d "$dir_absolute" ] && echo "No directory found at $dir. Skipping" && continue - cd "$dir_absolute" - temp_venv=$(mktemp -d) - python -m venv "$temp_venv" - source "$temp_venv/bin/activate" - [ -f "requirements.txt" ] && pip install -r requirements.txt - pip install pylint pytest - echo "Running python lint and test on \"$dir\"" - pylint . - pytest . -done diff --git a/terraform/services/010-config/values/prod.sopsw.yaml b/terraform/services/010-config/values/prod.sopsw.yaml index 414f170d..828748cc 100644 --- a/terraform/services/010-config/values/prod.sopsw.yaml +++ b/terraform/services/010-config/values/prod.sopsw.yaml @@ -1,5 +1,6 @@ /dasgapi/sensitive/datadog/init_api_key: ENC[AES256_GCM,data:M5NFBuIPo2FaB7hssb35UkKQSTpVd0SIYSYkbKqXVA4=,iv:fkrQx/gJYcUHkCByRZV4T1dBkp36Aagso23ayO/C63A=,tag:k5LGV/Y0IhR+mIriqzYfWQ==,type:str] /dasgapi/sensitive/datadog/init_application_key: ENC[AES256_GCM,data:ESjlQ9h0lorD6onKsSmVVGUzv+EPPI7qUoVay751LfK5/UOJhJ1TpA==,iv:Uj9VQnhdnqT2SPEjm9lVCB2unhlEINiU500u4keGfIk=,tag:DoKkFKJT/adXDQ2WJqdwqw==,type:str] +/cdap/${env}/snyk/api_token: ENC[AES256_GCM,data:NjOThPKiEFGj4YZdJOK1l0zBtjJmUdSHGin0UQv5nQj80UQe,iv:wMT3hQSPBwY+hhNrcppnL8Y6AgptvphDUvm04K8jKo0=,tag:31S/Hr10uZzCh/Py36smpQ==,type:str] /cdap/${env}/codebuild-projects/sensitive/github-token: ENC[AES256_GCM,data:wFLx26HtKKxHK974KB4VBppkg/2zISBFtqkPACSKNDR9Z7lh7QwaBA==,iv:Wtka6L3sszviRYVIxti0sc3dd20iqPsnuZfZM5KoWpg=,tag:GjGGT14FBScFhQsX6+421w==,type:str] /cdap/${env}/common/nonsensitive/artifactory/url: https://artifactory.cloud.cms.gov/artifactory /cdap/${env}/common/nonsensitive/artifactory/user: ab2d-bcda-dpc-plt diff --git a/terraform/services/010-config/values/test.sopsw.yaml b/terraform/services/010-config/values/test.sopsw.yaml index 34e4b426..ca9f23e2 100644 --- a/terraform/services/010-config/values/test.sopsw.yaml +++ b/terraform/services/010-config/values/test.sopsw.yaml @@ -1,5 +1,6 @@ /dasgapi/sensitive/datadog/init_api_key: ENC[AES256_GCM,data:lrsX4aFEBNeJIWvSH6v/YOenRAKsg8xK498t+294cFE=,iv:OK+3pksyNBX4ZKIqP1iHiSa9F16tDRxwesrbGKr3MmI=,tag:1S0u/0t009+il1BOKeRSig==,type:str] /dasgapi/sensitive/datadog/init_application_key: ENC[AES256_GCM,data:zhTGraEUCn8/e4Bv3VrvZxJWokjct1AM0xEtZB+TMnlw5tGB0aF5Dw==,iv:9ni/W8QkrBgQg1b+aOSFR5LI5iqGdsYAMJ6Qz2QNEzw=,tag:1kfE4ir9PIY4DPoQ6xibJg==,type:str] +/cdap/${env}/snyk/api_token: ENC[AES256_GCM,data:77PUOXcaJH/gTQBEDA5wHUSNXAOgseQUGLAezXYtQ4w6Tu+p,iv:GT95QoEquPKQaE1cLoKXP8v6JgzX/WCeAzWiwPNm5Qc=,tag:CI5RIZ9ASUnAe0eaFZFlfg==,type:str] /cdap/${env}/codebuild-projects/sensitive/github-token: ENC[AES256_GCM,data:VKS1HuHBEX/0GCtTEUh3Jp2ZqwYhF98xEeG00gK2drhaQwIlyB30+w==,iv:bJN3CXHDDcQ+vP6/0RMiVcY2gCzsSrZyvtsoMgvfRjc=,tag:TdnG/kXR9gt/aZbc1wLpbw==,type:str] /cdap/${env}/common/nonsensitive/artifactory/url: https://artifactory.cloud.cms.gov/artifactory /cdap/${env}/common/nonsensitive/artifactory/user: ab2d-bcda-dpc-plt From 7ef973737d18828b5147f7a5d9b5124cd0dc8bdc Mon Sep 17 00:00:00 2001 From: mianava Date: Tue, 5 May 2026 17:25:36 -0400 Subject: [PATCH 18/30] Remove outdated documentation --- terraform/modules/function/variables.tf | 4 ---- 1 file changed, 4 deletions(-) diff --git a/terraform/modules/function/variables.tf b/terraform/modules/function/variables.tf index b07d42f3..8d3acb3e 100644 --- a/terraform/modules/function/variables.tf +++ b/terraform/modules/function/variables.tf @@ -101,10 +101,6 @@ variable "liveness_check_enabled" { surfaces as a function error and causes the Tofu apply to fail, alerting the deploying team immediately. - See the liveness_check_handler_key variable for the expected event shape, - and refer to your function's documentation for what the liveness check - validates. - Recommended: true in all environments to catch misconfiguration at deploy time. EOT type = bool From 4d6a342e40e09e1616dd26d4e691bbb911322beb Mon Sep 17 00:00:00 2001 From: mianava Date: Tue, 5 May 2026 21:45:50 -0400 Subject: [PATCH 19/30] Add rollback support --- .github/workflows/tftesting-function.yml | 0 terraform/modules/function/README.md | 11 ++++++++++- terraform/modules/function/main.tf | 9 ++++++++- terraform/modules/function/outputs.tf | 10 ++++++++++ terraform/modules/function/variables.tf | 6 ++++++ terraform/services/tftesting/function/iam.tf | 0 .../tftesting/function/lambda_src/lambda_function.py | 0 terraform/services/tftesting/function/main.tf | 0 terraform/services/tftesting/function/outputs.tf | 0 terraform/services/tftesting/function/tofu.tf | 0 10 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/tftesting-function.yml create mode 100644 terraform/services/tftesting/function/iam.tf create mode 100644 terraform/services/tftesting/function/lambda_src/lambda_function.py create mode 100644 terraform/services/tftesting/function/main.tf create mode 100644 terraform/services/tftesting/function/outputs.tf create mode 100644 terraform/services/tftesting/function/tofu.tf diff --git a/.github/workflows/tftesting-function.yml b/.github/workflows/tftesting-function.yml new file mode 100644 index 00000000..e69de29b diff --git a/terraform/modules/function/README.md b/terraform/modules/function/README.md index aa25794b..2c4d1c60 100644 --- a/terraform/modules/function/README.md +++ b/terraform/modules/function/README.md @@ -42,15 +42,17 @@ No requirements. | [env](#input\_env) | The application environment (dev, test, sandbox, prod) | `string` | n/a | yes | | [name](#input\_name) | Name of the lambda function | `string` | n/a | yes | | [architecture](#input\_architecture) | Lambda function CPU architecture. Use arm64 for Graviton (better price/performance for most workloads). | `string` | `"x86_64"` | no | -| [create\_function\_zip](#input\_create\_function\_zip) | Upload a dummy zip to initialize the S3 bucket on first apply.
Has no effect and should not be set to true when source\_dir is provided,
as the module will manage the zip and upload automatically. | `bool` | `false` | no | +| [egress\_rules](#input\_egress\_rules) | List of egress rules to apply to the security group |
list(object({
name = string
from_port = number
to_port = number
protocol = string
cidr_ipv4 = optional(string)
cidr_ipv6 = optional(string)
referenced_sg_id = optional(string)
description = optional(string)
}))
|
[
{
"cidr_ipv4": "0.0.0.0/0",
"description": "Allow all egress traffic (IPv4) - migration default",
"from_port": 0,
"name": "allow-all-ipv4",
"protocol": "-1",
"to_port": 0
},
{
"cidr_ipv6": "::/0",
"description": "Allow all egress traffic (IPv6) - migration default",
"from_port": 0,
"name": "allow-all-ipv6",
"protocol": "-1",
"to_port": 0
}
]
| no | | [environment\_variables](#input\_environment\_variables) | Map of environment variables for the function | `map(string)` | `{}` | no | | [extra\_kms\_key\_arns](#input\_extra\_kms\_key\_arns) | Optional list of additional KMS key ARNs the Lambda can use | `list(string)` | `[]` | no | | [function\_role\_inline\_policies](#input\_function\_role\_inline\_policies) | Inline policies (in JSON) for the function IAM role | `map(string)` | `{}` | no | | [github\_actions\_repos](#input\_github\_actions\_repos) | Used for integration tests and, when source\_dir is null,
for CI/CD workflows that upload the function zip.
Format: "repo:CMSgov/:*" or a more specific ref pattern.
Defaults to empty — no GitHub Actions access unless explicitly granted. | `list(string)` | `[]` | no | | [handler](#input\_handler) | Lambda function handler | `string` | `"function_handler"` | no | | [layer\_arns](#input\_layer\_arns) | Optional list of layer arns | `list(string)` | `[]` | no | +| [liveness\_check\_enabled](#input\_liveness\_check\_enabled) | Enables a deploy-time liveness check that invokes the Lambda function
immediately after deployment to verify it is healthy and correctly configured.

When enabled, an aws\_lambda\_invocation resource is created that sends a
{ "RequestType": "LivenessCheck" } payload to the Lambda function after
each deployment. The invocation is re-triggered whenever the Lambda source
code changes (tracked via source\_code\_hash).

The Lambda function is responsible for implementing the liveness check logic
in its handler. This may include verifying external dependencies, validating
configuration, checking connectivity to downstream services, or any other
health validation relevant to the function's purpose.

If the liveness check fails, the Lambda should raise an exception. This
surfaces as a function error and causes the Tofu apply to fail, alerting
the deploying team immediately.

Recommended: true in all environments to catch misconfiguration at deploy time. | `bool` | `true` | no | | [log\_retention\_days](#input\_log\_retention\_days) | Number of days to retain Lambda function logs in CloudWatch. If null, no retention policy is set and retention is managed externally (e.g., via cdap/scripts/set\_log\_retention/). | `number` | `180` | no | | [memory\_size](#input\_memory\_size) | Lambda function memory size | `number` | `null` | no | +| [rollback\_version](#input\_rollback\_version) | Pin the live alias to a specific version for rollback. Set to null for normal deploys (alias tracks latest published version). | `string` | `null` | no | | [runtime](#input\_runtime) | Lambda function runtime | `string` | `"python3.11"` | no | | [schedule\_expression](#input\_schedule\_expression) | Cron or rate expression for a scheduled function | `string` | `""` | no | | [source\_code\_version](#input\_source\_code\_version) | Optional S3 object version of function.zip uploaded to module's zip\_bucket by external sources. | `string` | `null` | no | @@ -87,11 +89,16 @@ No requirements. | [aws_iam_role.function](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role_policy.default_function](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.extra_policies](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_lambda_alias.live](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_alias) | resource | | [aws_lambda_function.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | +| [aws_lambda_invocation.liveness_check](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_invocation) | resource | | [aws_lambda_permission.cloudwatch_events](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | | [aws_s3_object.empty_function_zip](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_object) | resource | | [aws_s3_object.function_zip](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_object) | resource | | [aws_security_group.function](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [aws_vpc_security_group_egress_rule.ipv4](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_egress_rule) | resource | +| [aws_vpc_security_group_egress_rule.ipv6](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_egress_rule) | resource | +| [aws_vpc_security_group_egress_rule.sg_source](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_egress_rule) | resource | | [archive_file.function](https://registry.terraform.io/providers/hashicorp/archive/latest/docs/data-sources/file) | data source | | [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | | [aws_iam_openid_connect_provider.github](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_openid_connect_provider) | data source | @@ -111,6 +118,8 @@ No requirements. | Name | Description | |------|-------------| +| [alias\_arn](#output\_alias\_arn) | ARN of the live alias | +| [function\_version](#output\_function\_version) | Published version number of the Lambda function | | [name](#output\_name) | Name for the lambda function | | [role\_arn](#output\_role\_arn) | ARN of the IAM role for the function | | [security\_group\_id](#output\_security\_group\_id) | ID for the security group for the function | diff --git a/terraform/modules/function/main.tf b/terraform/modules/function/main.tf index b22bd657..a7833561 100644 --- a/terraform/modules/function/main.tf +++ b/terraform/modules/function/main.tf @@ -175,8 +175,9 @@ resource "aws_cloudwatch_log_group" "function" { } resource "aws_lambda_invocation" "liveness_check" { - count = liveness_check_enabled ? 1 : 0 + count = var.liveness_check_enabled ? 1 : 0 function_name = aws_lambda_function.this.function_name + qualifier = aws_lambda_alias.live.name # Re-runs whenever the Lambda source code changes triggers = { @@ -187,3 +188,9 @@ resource "aws_lambda_invocation" "liveness_check" { RequestType = "LivenessCheck" }) } + +resource "aws_lambda_alias" "live" { + name = "live" + function_name = aws_lambda_function.this.function_name + function_version = var.rollback_version != null ? var.rollback_version : aws_lambda_function.this.version +} diff --git a/terraform/modules/function/outputs.tf b/terraform/modules/function/outputs.tf index ebde26b3..6a0d77c2 100644 --- a/terraform/modules/function/outputs.tf +++ b/terraform/modules/function/outputs.tf @@ -3,6 +3,16 @@ output "name" { value = aws_lambda_function.this.function_name } +output "alias_arn" { + description = "ARN of the live alias" + value = aws_lambda_alias.live.arn +} + +output "function_version" { + description = "Published version number of the Lambda function" + value = aws_lambda_function.this.version +} + output "role_arn" { description = "ARN of the IAM role for the function" value = aws_iam_role.function.arn diff --git a/terraform/modules/function/variables.tf b/terraform/modules/function/variables.tf index 8d3acb3e..0c80a40b 100644 --- a/terraform/modules/function/variables.tf +++ b/terraform/modules/function/variables.tf @@ -107,6 +107,12 @@ variable "liveness_check_enabled" { default = true } +variable "rollback_version" { + description = "Pin the live alias to a specific version for rollback. Set to null for normal deploys (alias tracks latest published version)." + type = string + default = null +} + # ── Runtime Behavior ────────────────────────────────────────────────────────── variable "environment_variables" { diff --git a/terraform/services/tftesting/function/iam.tf b/terraform/services/tftesting/function/iam.tf new file mode 100644 index 00000000..e69de29b diff --git a/terraform/services/tftesting/function/lambda_src/lambda_function.py b/terraform/services/tftesting/function/lambda_src/lambda_function.py new file mode 100644 index 00000000..e69de29b diff --git a/terraform/services/tftesting/function/main.tf b/terraform/services/tftesting/function/main.tf new file mode 100644 index 00000000..e69de29b diff --git a/terraform/services/tftesting/function/outputs.tf b/terraform/services/tftesting/function/outputs.tf new file mode 100644 index 00000000..e69de29b diff --git a/terraform/services/tftesting/function/tofu.tf b/terraform/services/tftesting/function/tofu.tf new file mode 100644 index 00000000..e69de29b From 8badb99d13c6775990b584dfa38ddcbc40742780 Mon Sep 17 00:00:00 2001 From: mianava Date: Tue, 5 May 2026 21:46:43 -0400 Subject: [PATCH 20/30] Add lambda function for testing --- terraform/services/tftesting/function/iam.tf | 10 +++ .../function/lambda_src/lambda_function.py | 45 ++++++++++ terraform/services/tftesting/function/main.tf | 86 +++++++++++++++++++ .../services/tftesting/function/outputs.tf | 14 +++ terraform/services/tftesting/function/tofu.tf | 20 +++++ 5 files changed, 175 insertions(+) diff --git a/terraform/services/tftesting/function/iam.tf b/terraform/services/tftesting/function/iam.tf index e69de29b..c5f9634e 100644 --- a/terraform/services/tftesting/function/iam.tf +++ b/terraform/services/tftesting/function/iam.tf @@ -0,0 +1,10 @@ +data "aws_iam_policy_document" "ssm_inline_test" { + statement { + sid = "InlinePolicySSMRead" + effect = "Allow" + actions = [ + "ssm:GetParameter" + ] + resources = [aws_ssm_parameter.inline_policy_test.arn] + } +} diff --git a/terraform/services/tftesting/function/lambda_src/lambda_function.py b/terraform/services/tftesting/function/lambda_src/lambda_function.py index e69de29b..09c9c4cd 100644 --- a/terraform/services/tftesting/function/lambda_src/lambda_function.py +++ b/terraform/services/tftesting/function/lambda_src/lambda_function.py @@ -0,0 +1,45 @@ +import json +import logging +import os + +import boto3 + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +ssm = boto3.client("ssm") + + +def function_handler(event, context): + logger.info("Received event: %s", json.dumps(event)) + + request_type = event.get("RequestType") or event.get("source", "") + + if request_type == "LivenessCheck": + return _liveness_check() + + logger.warning("Unknown RequestType: %s", request_type) + return {"status": "ok", "event": event} + + +def _liveness_check(): + """ + Validates that the function can reach dependencies. + Raises on failure so tofu apply fails. + """ + param_name = os.environ["SSM_PARAM_PATH"] + + # Validates: egress rules, IAM SSM permissions, KMS decrypt permission + response = ssm.get_parameter(Name=param_name, WithDecryption=True) + value = response["Parameter"]["Value"] + + if not value: + raise ValueError("SSM parameter was empty") + + if os.environ.get("ENVIRONMENT") != "tftesting": + raise ValueError( + f"ENVIRONMENT env var not set correctly: {os.environ.get('ENVIRONMENT')!r}" + ) + + logger.info("Liveness check passed. SSM value retrieved successfully.") + return {"status": "ok", "message": "Lambda is healthy"} diff --git a/terraform/services/tftesting/function/main.tf b/terraform/services/tftesting/function/main.tf index e69de29b..e9f1ef53 100644 --- a/terraform/services/tftesting/function/main.tf +++ b/terraform/services/tftesting/function/main.tf @@ -0,0 +1,86 @@ +resource "aws_ssm_parameter" "test_config" { + name = "/cdap/test/tftesting/function/testvalue" + # only setting as secure for testing + type = "SecureString" + # not an actually secure string + value = "tftesting" + + key_id = module.platform.aws_kms_alias.primary +} + +# This parameter is NOT in ssm_parameter_paths — only accessible via inline policy +resource "aws_ssm_parameter" "inline_policy_test" { + name = "/cdap/test/tftesting/function/inline-policy-test" + type = "SecureString" + value = "inline-policy-access-confirmed" + key_id = module.platform.aws_kms_alias.primary +} + +module "test_lambda" { + source = "../../../modules/lambda" + + app = "cdap" + env = "test" + name = "tftesting-function" + description = "Ephemeral Lambda for CI/CD integration testing — exercises module features" + + source_dir = "${path.module}/lambda_src" + source_dir_excludes = ["**/__pycache__/**", "**/*.pyc", "**/tests/**"] + + handler = "lambda_function.function_handler" + runtime = "python3.11" + architecture = "arm64" + timeout = 30 + memory_size = 256 # Evaluates non-default memory + + liveness_check_enabled = true + + log_retention_days = 7 + + # Exercises environment_variables + environment_variables = { + ENVIRONMENT = "tftesting" + SSM_PARAM_PATH = aws_ssm_parameter.test_config.name + INLINE_POLICY_PARAM_PATH = aws_ssm_parameter.inline_policy_test.name + } + + # Exercises ssm_parameter_paths + ssm_parameter_paths = [aws_ssm_parameter.test_config.arn] + + # Exercises schedule_expression — can be set for scheduler testing + schedule_expression = "" + + # Exercises function_role_inline_policies — + function_role_inline_policies = { + "ssm-inline-test" = data.aws_iam_policy_document.ssm_inline_test.json + } + + # Placeholder if evaluating github_actions_repos for deploys outside of Tofu + github_actions_repos = [] + + # Scoped egress — HTTPS only to allow testing of SSM parameter retrieval ; remove when VPC endpoint is introduced + egress_rules = [ + { + name = "allow-https-ipv4" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_ipv4 = "0.0.0.0/0" + description = "Allow HTTPS egress for AWS API calls" + } + ] + + # Rollback support + rollback_version = null # null = track latest published version +} + + +module "platform" { + providers = { aws = aws, aws.secondary = aws.secondary } + + source = "../../../modules/platform" + app = "cdap" + env = "test" + root_module = "https://github.com/CMSgov/cdap/tree/main/terraform/services/tftesting/${basename(abspath(path.module))}/" + service = replace(basename(abspath(path.module)), "/^[0-9]+-/", "") +} diff --git a/terraform/services/tftesting/function/outputs.tf b/terraform/services/tftesting/function/outputs.tf index e69de29b..44afd1ce 100644 --- a/terraform/services/tftesting/function/outputs.tf +++ b/terraform/services/tftesting/function/outputs.tf @@ -0,0 +1,14 @@ +output "function_name" { + description = "Name of the test Lambda function" + value = module.test_lambda.function_name +} + +output "function_arn" { + description = "ARN of the test Lambda function" + value = module.test_lambda.alias_arn +} + +output "function_version" { + description = "Published version of the test Lambda" + value = module.test_lambda.function_version +} diff --git a/terraform/services/tftesting/function/tofu.tf b/terraform/services/tftesting/function/tofu.tf index e69de29b..d9149467 100644 --- a/terraform/services/tftesting/function/tofu.tf +++ b/terraform/services/tftesting/function/tofu.tf @@ -0,0 +1,20 @@ +provider "aws" { + region = "us-east-1" + default_tags { + tags = module.platform.default_tags + } +} + +provider "aws" { + alias = "secondary" + region = "us-west-2" + default_tags { + tags = module.platform.default_tags + } +} + +terraform { + backend "s3" { + key = "tftesting/function/terraform.tfstate" + } +} From 42e7ed7646c4fda1953fb8a0204074511862b551 Mon Sep 17 00:00:00 2001 From: mianava Date: Tue, 5 May 2026 21:46:58 -0400 Subject: [PATCH 21/30] Add workflow to test lambda function and tear down upon success. --- .github/workflows/tftesting-function.yml | 73 ++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/.github/workflows/tftesting-function.yml b/.github/workflows/tftesting-function.yml index e69de29b..d32043f5 100644 --- a/.github/workflows/tftesting-function.yml +++ b/.github/workflows/tftesting-function.yml @@ -0,0 +1,73 @@ +name: tftesting-lambda + +on: + workflow_dispatch: + pull_request: + paths: + - 'terraform/services/tftesting/function/**' + - 'terraform/modules/function/**' + - '.github/workflows/tftesting-function.yml' + push: + branches: + - main + paths: + - 'terraform/services/tftesting/function/**' + - 'terraform/modules/function/**' + - '.github/workflows/tftesting-function.yml' + +concurrency: + group: tftesting-function + cancel-in-progress: true + +env: + TENV_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + APP: cdap + ENV: test + TF_DIR: terraform/services/tftesting/function + +permissions: + contents: read + id-token: write + +jobs: + apply: + name: Tofu Apply + runs-on: codebuild-cdap-non-prod-${{ github.run_id }}-${{ github.run_attempt }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.3.0 + - uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 + - uses: cmsgov/cdap/actions/setup-tenv@f4c14d47cc20e7f6de9112d7155af1213c9bca5a + - uses: cmsgov/cdap/actions/setup-sops@f4c14d47cc20e7f6de9112d7155af1213c9bca5a + - uses: cmsgov/cdap/actions/setup-yq@f4c14d47cc20e7f6de9112d7155af1213c9bca5a + - uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 + with: + role-to-assume: arn:aws:iam::${{ secrets.NON_PROD_ACCOUNT }}:role/delegatedadmin/developer/${{ env.APP }}-${{ env.ENV }}-github-actions + aws-region: ${{ vars.AWS_REGION }} + - name: Tofu Init + working-directory: ${{ env.TF_DIR }} + run: tofu init + - name: Tofu Apply + working-directory: ${{ env.TF_DIR }} + run: tofu apply -auto-approve + + destroy: + name: Tofu Destroy + if: success() + needs: apply + runs-on: codebuild-cdap-non-prod-${{ github.run_id }}-${{ github.run_attempt }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.3.0 + - uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 + - uses: cmsgov/cdap/actions/setup-tenv@f4c14d47cc20e7f6de9112d7155af1213c9bca5a + - uses: cmsgov/cdap/actions/setup-sops@f4c14d47cc20e7f6de9112d7155af1213c9bca5a + - uses: cmsgov/cdap/actions/setup-yq@f4c14d47cc20e7f6de9112d7155af1213c9bca5a + - uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 + with: + role-to-assume: arn:aws:iam::${{ secrets.NON_PROD_ACCOUNT }}:role/delegatedadmin/developer/${{ env.APP }}-${{ env.ENV }}-github-actions + aws-region: ${{ vars.AWS_REGION }} + - name: Tofu Init + working-directory: ${{ env.TF_DIR }} + run: tofu init + - name: Tofu Destroy + working-directory: ${{ env.TF_DIR }} + run: tofu destroy -auto-approve From 0bf3a7a2d0f88708aacfce111a52cc4f10aaa18e Mon Sep 17 00:00:00 2001 From: mianava Date: Tue, 5 May 2026 22:45:28 -0400 Subject: [PATCH 22/30] Updating testing for alarm to slack lambda --- .github/workflows/alarm-to-slack-checks.yml | 14 +++ .../workflows/set_log_retention_checks.yml | 1 - .github/workflows/tftesting-function.yml | 5 +- .../lambda_src/lambda_function.py | 37 ++++---- .../lambda_src/test_lambda_function.py | 87 +++++++++++++++++-- terraform/services/alarm-to-slack/main.tf | 2 +- 6 files changed, 115 insertions(+), 31 deletions(-) diff --git a/.github/workflows/alarm-to-slack-checks.yml b/.github/workflows/alarm-to-slack-checks.yml index 582e1e2b..4885bd99 100644 --- a/.github/workflows/alarm-to-slack-checks.yml +++ b/.github/workflows/alarm-to-slack-checks.yml @@ -16,3 +16,17 @@ jobs: source_path: terraform/services/alarm-to-slack/lambda_src sonar_project_key: cdap-alarm-to-slack sonar_project_name: "CDAP Alarm to Slack" + + python-tests: + runs-on: codebuild-cdap-${{ github.ref_name == 'main' && 'prod' || 'non-prod' }}-${{ github.run_id }}-${{ github.run_attempt }} + defaults: + run: + working-directory: terraform/services/alarm-to-slack/lambda_src + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.3.0 + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install pytest pytest-cov + - name: Run tests + run: pytest test_lambda_function.py -v --cov=lambda_function --cov-report=term-missing diff --git a/.github/workflows/set_log_retention_checks.yml b/.github/workflows/set_log_retention_checks.yml index 2339c08b..c49abf78 100644 --- a/.github/workflows/set_log_retention_checks.yml +++ b/.github/workflows/set_log_retention_checks.yml @@ -7,7 +7,6 @@ on: paths: - 'scripts/set_log_retention/*.py' - 'scripts/set_log_retention/requirements.txt' - - 'terraform/modules/function/**' jobs: python-checks: diff --git a/.github/workflows/tftesting-function.yml b/.github/workflows/tftesting-function.yml index d32043f5..ee2ed229 100644 --- a/.github/workflows/tftesting-function.yml +++ b/.github/workflows/tftesting-function.yml @@ -37,7 +37,6 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.3.0 - uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - uses: cmsgov/cdap/actions/setup-tenv@f4c14d47cc20e7f6de9112d7155af1213c9bca5a - - uses: cmsgov/cdap/actions/setup-sops@f4c14d47cc20e7f6de9112d7155af1213c9bca5a - uses: cmsgov/cdap/actions/setup-yq@f4c14d47cc20e7f6de9112d7155af1213c9bca5a - uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: @@ -45,7 +44,7 @@ jobs: aws-region: ${{ vars.AWS_REGION }} - name: Tofu Init working-directory: ${{ env.TF_DIR }} - run: tofu init + run: tofu init -reconfigure -backend-config="../../../backends/${{ env.APP }}-${{ env.ENV }}.s3.tfbackend" - name: Tofu Apply working-directory: ${{ env.TF_DIR }} run: tofu apply -auto-approve @@ -67,7 +66,7 @@ jobs: aws-region: ${{ vars.AWS_REGION }} - name: Tofu Init working-directory: ${{ env.TF_DIR }} - run: tofu init + run: tofu init -reconfigure -backend-config="../../backends/${{ env.APP }}-${{ env.ENV }}.s3.tfbackend" - name: Tofu Destroy working-directory: ${{ env.TF_DIR }} run: tofu destroy -auto-approve diff --git a/terraform/services/alarm-to-slack/lambda_src/lambda_function.py b/terraform/services/alarm-to-slack/lambda_src/lambda_function.py index 105fc7e6..511f511f 100644 --- a/terraform/services/alarm-to-slack/lambda_src/lambda_function.py +++ b/terraform/services/alarm-to-slack/lambda_src/lambda_function.py @@ -7,7 +7,7 @@ import json import os from urllib import request -from urllib.error import URLError +from urllib.error import URLError, HTTPError import boto3 from botocore.exceptions import ClientError @@ -44,37 +44,40 @@ def is_ignore_ok(): """ return os.environ.get('IGNORE_OK', 'false').lower() == 'true' + def ping_slack_webhook(webhook, app, message_id=None): """ - Sends a liveness ping to a Slack webhook using Slack's no-op endpoint pattern. - Uses an empty payload to trigger a validation response from Slack without - posting a visible message. Returns True if Slack responds with 200, False otherwise. - - Note: Slack returns 400 for empty/invalid payloads, but a 400 still confirms - the webhook URL is reachable and valid. A URLError or non-reachable host - indicates a broken webhook. + Sends a liveness ping to a Slack webhook using an empty payload. + Slack returns 400 for empty payloads, but a 400 still confirms the + webhook URL is reachable. A URLError or non-reachable host indicates + a broken webhook. """ try: - # Send minimal JSON — Slack will return 400 "no_text" but the URL is reachable jsondata = json.dumps({}).encode('utf-8') req = request.Request(webhook) req.add_header('Content-Type', 'application/json; charset=utf-8') req.add_header('Content-Length', str(len(jsondata))) with request.urlopen(req, jsondata) as resp: - log({'msg': f'Liveness ping succeeded for app: {app}', 'status': resp.status, - 'messageId': message_id}) + log({'msg': f'Liveness ping succeeded for app: {app}', + 'status': resp.status, 'messageId': message_id}) return True - except URLError as e: - # Slack returns HTTP 400 for empty payloads, which raises URLError in urllib. - # A 400 still means the webhook URL is reachable — treat it as alive. - reason = str(e.reason) - if hasattr(e, 'code') and e.code == 400: + except HTTPError as e: + # Slack returns 400 for empty payloads — still means the URL is reachable + if e.code == 400: log({'msg': f'Liveness ping reachable (400 expected) for app: {app}', 'messageId': message_id}) return True - log({'msg': f'Liveness ping FAILED for app: {app}, reason: {reason}', + log({'msg': f'Liveness ping FAILED (HTTP {e.code}) for app: {app}', 'messageId': message_id}) return False + except URLError as e: + log({'msg': f'Liveness ping FAILED for app: {app}, reason: {e.reason}', + 'messageId': message_id}) + return False + +def get_app_list(): + apps_env = os.environ.get('APPS', '') + return [app.strip() for app in apps_env.split(',') if app.strip()] def liveness_check(): """ diff --git a/terraform/services/alarm-to-slack/lambda_src/test_lambda_function.py b/terraform/services/alarm-to-slack/lambda_src/test_lambda_function.py index 66cc1557..58167bf0 100644 --- a/terraform/services/alarm-to-slack/lambda_src/test_lambda_function.py +++ b/terraform/services/alarm-to-slack/lambda_src/test_lambda_function.py @@ -28,12 +28,6 @@ def mock_boto3_client(): with patch('lambda_function.boto3.client') as mock_client: yield mock_client -def reload_lambda(): - """Reload the lambda_function module to pick up environment variable changes.""" - if 'lambda_function' in sys.modules: - importlib.reload(sys.modules['lambda_function']) - return sys.modules['lambda_function'] - def test_cloudwatch_message_sqs_record(): """Test parsing a valid CloudWatch message from an SQS record.""" cloudwatch_message = { @@ -77,7 +71,6 @@ def test_enriched_cloudwatch_message_alarm_record(): @patch.dict(os.environ, {'IGNORE_OK': 'false'}, clear=True) def test_enriched_cloudwatch_message_alarm_record_ok_ignored(): """Test enrichment when IGNORE_OK is false and state is ALARM.""" - reload_lambda() cloudwatch_message = { 'AlarmName': 'bcda-dev-SomeAlarm', 'OldStateValue': 'OK', @@ -121,7 +114,6 @@ def test_enriched_cloudwatch_message_ok_record(): @patch.dict(os.environ, {'IGNORE_OK': 'false'}, clear=True) def test_enriched_cloudwatch_message_ok_record_ignore_false(): """Test OK state message with IGNORE_OK explicitly set to false.""" - reload_lambda() cloudwatch_message = { 'AlarmName': 'bcda-dev-SomeAlarm', 'OldStateValue': 'ALARM', @@ -144,7 +136,6 @@ def test_enriched_cloudwatch_message_ok_record_ignore_false(): @patch.dict(os.environ, {'IGNORE_OK': 'true'}, clear=True) def test_enriched_cloudwatch_message_ok_record_ok_ignored(): """Test that OK state message is ignored when IGNORE_OK is true.""" - reload_lambda() cloudwatch_message = { 'AlarmName': 'bcda-dev-SomeAlarm', 'OldStateValue': 'ALARM', @@ -259,3 +250,81 @@ def test_logger(capsys): log_output = json.loads(captured.out) assert log_output['test'] == 'log' assert 'time' in log_output + +@patch.dict(os.environ, {'APPS': 'bcda,cdap,dpc'}, clear=True) +def test_get_app_list_returns_list(): + """Test that APPS env var is parsed into a list correctly.""" + assert lambda_function.get_app_list() == ['bcda', 'cdap', 'dpc'] + +@patch.dict(os.environ, {}, clear=True) +def test_get_app_list_empty(): + """Test that missing APPS env var returns empty list.""" + assert lambda_function.get_app_list() == [] + +# ── ping_slack_webhook ───────────────────────────────────────────────────── + +@patch('urllib.request.urlopen') +def test_ping_slack_webhook_success(mock_urlopen): + """200 response → reachable.""" + cm = MagicMock() + cm.status = 200 + cm.__enter__.return_value = cm + mock_urlopen.return_value = cm + assert lambda_function.ping_slack_webhook('https://hooks.slack.com/test', 'bcda') is True + + +@patch('urllib.request.urlopen') +def test_ping_slack_webhook_400_treated_as_alive(mock_urlopen): + """Slack's 400 for empty payload still means the URL is reachable.""" + from urllib.error import HTTPError + mock_urlopen.side_effect = HTTPError( + url='https://hooks.slack.com/test', code=400, + msg='no_text', hdrs=None, fp=None, + ) + assert lambda_function.ping_slack_webhook('https://hooks.slack.com/test', 'bcda') is True + + +@patch('urllib.request.urlopen') +def test_ping_slack_webhook_network_failure(mock_urlopen): + """Genuine network error → not reachable.""" + from urllib.error import URLError + mock_urlopen.side_effect = URLError('connection refused') + assert lambda_function.ping_slack_webhook('https://hooks.slack.com/test', 'bcda') is False + + +# ── liveness_check ───────────────────────────────────────────────────────── + +@patch.dict(os.environ, {'APPS': 'bcda,dpc'}, clear=True) +@patch('lambda_function.ping_slack_webhook', return_value=True) +@patch('lambda_function.get_ssm_parameter', return_value='https://hooks.slack.com/test') +def test_liveness_check_all_ok(mock_ssm, mock_ping): + """All apps pass → all_ok is True.""" + result = lambda_function.liveness_check() + assert result['all_ok'] is True + + +@patch.dict(os.environ, {'APPS': 'bcda'}, clear=True) +@patch('lambda_function.get_ssm_parameter', return_value=None) +def test_liveness_check_ssm_missing(mock_ssm): + """Missing SSM parameter → app fails, all_ok is False.""" + result = lambda_function.liveness_check() + assert result['all_ok'] is False + assert result['results']['bcda']['ssm_ok'] is False + + +@patch.dict(os.environ, {'APPS': 'bcda'}, clear=True) +@patch('lambda_function.ping_slack_webhook', return_value=True) +@patch('lambda_function.get_ssm_parameter', return_value='https://hooks.slack.com/test') +def test_handle_liveness_event_passes(mock_ssm, mock_ping): + """Returns 200 when all checks pass.""" + response = lambda_function.handle_liveness_event({'RequestType': 'LivenessCheck'}) + assert response['statusCode'] == 200 + + +@patch.dict(os.environ, {'APPS': 'bcda'}, clear=True) +@patch('lambda_function.ping_slack_webhook', return_value=False) +@patch('lambda_function.get_ssm_parameter', return_value='https://hooks.slack.com/test') +def test_handle_liveness_event_raises_on_failure(mock_ssm, mock_ping): + """Raises RuntimeError when a check fails — surfaces as Lambda error in Tofu.""" + with pytest.raises(RuntimeError, match='bcda'): + lambda_function.handle_liveness_event({'RequestType': 'LivenessCheck'}) \ No newline at end of file diff --git a/terraform/services/alarm-to-slack/main.tf b/terraform/services/alarm-to-slack/main.tf index b43e1821..f9212072 100644 --- a/terraform/services/alarm-to-slack/main.tf +++ b/terraform/services/alarm-to-slack/main.tf @@ -45,7 +45,7 @@ module "sns_to_slack_function" { environment_variables = { IGNORE_OK = true - APPS = "bcda, dpc, ab2d" + APPS = join(",", var.apps_served) } } From 87431781d7445e0e63c12410747c44c871ccef8f Mon Sep 17 00:00:00 2001 From: mianava Date: Tue, 5 May 2026 22:46:03 -0400 Subject: [PATCH 23/30] Configure testable lambda function that's ephemeral. --- terraform/services/tftesting/function/main.tf | 8 ++++---- terraform/services/tftesting/function/outputs.tf | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/terraform/services/tftesting/function/main.tf b/terraform/services/tftesting/function/main.tf index e9f1ef53..66b28a88 100644 --- a/terraform/services/tftesting/function/main.tf +++ b/terraform/services/tftesting/function/main.tf @@ -5,7 +5,7 @@ resource "aws_ssm_parameter" "test_config" { # not an actually secure string value = "tftesting" - key_id = module.platform.aws_kms_alias.primary + key_id = module.platform.kms_alias_primary } # This parameter is NOT in ssm_parameter_paths — only accessible via inline policy @@ -13,11 +13,11 @@ resource "aws_ssm_parameter" "inline_policy_test" { name = "/cdap/test/tftesting/function/inline-policy-test" type = "SecureString" value = "inline-policy-access-confirmed" - key_id = module.platform.aws_kms_alias.primary + key_id = module.platform.kms_alias_primary } -module "test_lambda" { - source = "../../../modules/lambda" +module "tftesting_function" { + source = "../../../modules/function" app = "cdap" env = "test" diff --git a/terraform/services/tftesting/function/outputs.tf b/terraform/services/tftesting/function/outputs.tf index 44afd1ce..d98ce5e5 100644 --- a/terraform/services/tftesting/function/outputs.tf +++ b/terraform/services/tftesting/function/outputs.tf @@ -1,14 +1,14 @@ output "function_name" { description = "Name of the test Lambda function" - value = module.test_lambda.function_name + value = module.tftesting_function.name } output "function_arn" { description = "ARN of the test Lambda function" - value = module.test_lambda.alias_arn + value = module.tftesting_function.alias_arn } output "function_version" { description = "Published version of the test Lambda" - value = module.test_lambda.function_version + value = module.tftesting_function.function_version } From db1bee01b6184c56b0242347286c9ffaf04ac41b Mon Sep 17 00:00:00 2001 From: mianava Date: Tue, 5 May 2026 22:51:14 -0400 Subject: [PATCH 24/30] Lookup by id --- terraform/services/tftesting/function/main.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/terraform/services/tftesting/function/main.tf b/terraform/services/tftesting/function/main.tf index 66b28a88..66d935b1 100644 --- a/terraform/services/tftesting/function/main.tf +++ b/terraform/services/tftesting/function/main.tf @@ -5,7 +5,7 @@ resource "aws_ssm_parameter" "test_config" { # not an actually secure string value = "tftesting" - key_id = module.platform.kms_alias_primary + key_id = module.platform.kms_alias_primary.id } # This parameter is NOT in ssm_parameter_paths — only accessible via inline policy @@ -13,7 +13,7 @@ resource "aws_ssm_parameter" "inline_policy_test" { name = "/cdap/test/tftesting/function/inline-policy-test" type = "SecureString" value = "inline-policy-access-confirmed" - key_id = module.platform.kms_alias_primary + key_id = module.platform.kms_alias_primary.id } module "tftesting_function" { From e2beccba2805708dd21aa4482d0df629c5a3f267 Mon Sep 17 00:00:00 2001 From: mianava Date: Tue, 5 May 2026 23:14:32 -0400 Subject: [PATCH 25/30] Remove permissions boundary as no longer required --- terraform/modules/function/iam.tf | 2 -- 1 file changed, 2 deletions(-) diff --git a/terraform/modules/function/iam.tf b/terraform/modules/function/iam.tf index 0de3f555..6458e76e 100644 --- a/terraform/modules/function/iam.tf +++ b/terraform/modules/function/iam.tf @@ -108,8 +108,6 @@ resource "aws_iam_role" "function" { name = "${var.name}-function" path = "/delegatedadmin/developer/" - permissions_boundary = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/cms-cloud-admin/developer-boundary-policy" - assume_role_policy = data.aws_iam_policy_document.function_assume_role.json } From ef94db123e1bef61e54ded3042cd0ddd1574cd76 Mon Sep 17 00:00:00 2001 From: mianava Date: Tue, 5 May 2026 23:17:21 -0400 Subject: [PATCH 26/30] Set backend properly for destroy --- .github/workflows/tftesting-function.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tftesting-function.yml b/.github/workflows/tftesting-function.yml index ee2ed229..215611e1 100644 --- a/.github/workflows/tftesting-function.yml +++ b/.github/workflows/tftesting-function.yml @@ -66,7 +66,7 @@ jobs: aws-region: ${{ vars.AWS_REGION }} - name: Tofu Init working-directory: ${{ env.TF_DIR }} - run: tofu init -reconfigure -backend-config="../../backends/${{ env.APP }}-${{ env.ENV }}.s3.tfbackend" + run: tofu init -reconfigure -backend-config="../../../backends/${{ env.APP }}-${{ env.ENV }}.s3.tfbackend" - name: Tofu Destroy working-directory: ${{ env.TF_DIR }} run: tofu destroy -auto-approve From 01cc6475c870655f04d62ea1292b5a11717d7314 Mon Sep 17 00:00:00 2001 From: mianava Date: Tue, 5 May 2026 23:20:39 -0400 Subject: [PATCH 27/30] Run tests only and wait for other PR for code scannign --- .github/workflows/alarm-to-slack-checks.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/alarm-to-slack-checks.yml b/.github/workflows/alarm-to-slack-checks.yml index 4885bd99..8381a901 100644 --- a/.github/workflows/alarm-to-slack-checks.yml +++ b/.github/workflows/alarm-to-slack-checks.yml @@ -7,16 +7,8 @@ on: paths: - 'terraform/services/alarm-to-slack/lambda_src/**/*.py' - 'terraform/services/alarm-to-slack/lambda_src/**/requirements.txt' - - 'terraform/modules/function/**' jobs: - python-checks: - uses: ./.github/workflows/python-checks-reusable.yml - with: - source_path: terraform/services/alarm-to-slack/lambda_src - sonar_project_key: cdap-alarm-to-slack - sonar_project_name: "CDAP Alarm to Slack" - python-tests: runs-on: codebuild-cdap-${{ github.ref_name == 'main' && 'prod' || 'non-prod' }}-${{ github.run_id }}-${{ github.run_attempt }} defaults: From 829c64eeef66403111a371407e31289fb9a6f462 Mon Sep 17 00:00:00 2001 From: mianava Date: Tue, 5 May 2026 23:28:25 -0400 Subject: [PATCH 28/30] Ensure naming uniqueness --- terraform/modules/function/iam.tf | 4 ++-- terraform/modules/function/main.tf | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/terraform/modules/function/iam.tf b/terraform/modules/function/iam.tf index 6458e76e..542312ad 100644 --- a/terraform/modules/function/iam.tf +++ b/terraform/modules/function/iam.tf @@ -105,14 +105,14 @@ data "aws_iam_policy_document" "default_function" { } resource "aws_iam_role" "function" { - name = "${var.name}-function" + name = "${local.full_name_string}-function" path = "/delegatedadmin/developer/" assume_role_policy = data.aws_iam_policy_document.function_assume_role.json } resource "aws_iam_role_policy" "default_function" { - name = "default-function" + name = "${local.full_name_string}-default" role = aws_iam_role.function.id policy = data.aws_iam_policy_document.default_function.json } diff --git a/terraform/modules/function/main.tf b/terraform/modules/function/main.tf index a7833561..c3f0c7c1 100644 --- a/terraform/modules/function/main.tf +++ b/terraform/modules/function/main.tf @@ -1,5 +1,6 @@ locals { - provider_domain = "token.actions.githubusercontent.com" + provider_domain = "token.actions.githubusercontent.com" + full_name_string = "${var.app}-${var.env}-${var.name}" } data "aws_kms_alias" "kms_key" { @@ -22,7 +23,7 @@ module "zip_bucket" { additional_bucket_policies = length(var.github_actions_repos) > 0 ? [data.aws_iam_policy_document.cicd_manage_lambda_objects.json] : [] app = var.app env = var.env - name = "${var.name}-function" + name = "${var.app}-${var.env}-${var.name}-function" ssm_parameter = "/${var.app}/${var.env}/${var.name}-bucket" } @@ -63,8 +64,8 @@ module "subnets" { } resource "aws_security_group" "function" { - name = "${var.name}-function" - description = "For the ${var.name} function" + name = "${local.full_name_string}-function" + description = "For the ${local.full_name_string} function" vpc_id = module.vpc.id } @@ -112,7 +113,7 @@ resource "aws_vpc_security_group_egress_rule" "sg_source" { resource "aws_lambda_function" "this" { description = var.description - function_name = var.name + function_name = local.full_name_string s3_key = "function.zip" s3_bucket = module.zip_bucket.id # If source_dir is managed by this module, track the uploaded object version. @@ -144,7 +145,7 @@ resource "aws_lambda_function" "this" { resource "aws_cloudwatch_event_rule" "this" { count = var.schedule_expression != "" ? 1 : 0 - name = "${var.name}-function" + name = "${local.full_name_string}-function" description = "Trigger ${var.name} function" schedule_expression = var.schedule_expression } From a013663af22778f70e4a1dd9691b04216e2d30d3 Mon Sep 17 00:00:00 2001 From: mianava Date: Tue, 5 May 2026 23:30:56 -0400 Subject: [PATCH 29/30] Prevent redundant labeling --- terraform/services/tftesting/function/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terraform/services/tftesting/function/main.tf b/terraform/services/tftesting/function/main.tf index 66d935b1..83b8ee01 100644 --- a/terraform/services/tftesting/function/main.tf +++ b/terraform/services/tftesting/function/main.tf @@ -21,7 +21,7 @@ module "tftesting_function" { app = "cdap" env = "test" - name = "tftesting-function" + name = "tftesting" description = "Ephemeral Lambda for CI/CD integration testing — exercises module features" source_dir = "${path.module}/lambda_src" From 94ecab21af505a798935b51fc2779073d5db7b2a Mon Sep 17 00:00:00 2001 From: mianava Date: Wed, 6 May 2026 10:44:15 -0400 Subject: [PATCH 30/30] Remove unused actions --- .github/workflows/tftesting-function.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/tftesting-function.yml b/.github/workflows/tftesting-function.yml index 215611e1..b367daa1 100644 --- a/.github/workflows/tftesting-function.yml +++ b/.github/workflows/tftesting-function.yml @@ -35,9 +35,7 @@ jobs: runs-on: codebuild-cdap-non-prod-${{ github.run_id }}-${{ github.run_attempt }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.3.0 - - uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - uses: cmsgov/cdap/actions/setup-tenv@f4c14d47cc20e7f6de9112d7155af1213c9bca5a - - uses: cmsgov/cdap/actions/setup-yq@f4c14d47cc20e7f6de9112d7155af1213c9bca5a - uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: arn:aws:iam::${{ secrets.NON_PROD_ACCOUNT }}:role/delegatedadmin/developer/${{ env.APP }}-${{ env.ENV }}-github-actions @@ -47,7 +45,7 @@ jobs: run: tofu init -reconfigure -backend-config="../../../backends/${{ env.APP }}-${{ env.ENV }}.s3.tfbackend" - name: Tofu Apply working-directory: ${{ env.TF_DIR }} - run: tofu apply -auto-approve + run: tofu apply destroy: name: Tofu Destroy @@ -56,10 +54,7 @@ jobs: runs-on: codebuild-cdap-non-prod-${{ github.run_id }}-${{ github.run_attempt }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.3.0 - - uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - uses: cmsgov/cdap/actions/setup-tenv@f4c14d47cc20e7f6de9112d7155af1213c9bca5a - - uses: cmsgov/cdap/actions/setup-sops@f4c14d47cc20e7f6de9112d7155af1213c9bca5a - - uses: cmsgov/cdap/actions/setup-yq@f4c14d47cc20e7f6de9112d7155af1213c9bca5a - uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: arn:aws:iam::${{ secrets.NON_PROD_ACCOUNT }}:role/delegatedadmin/developer/${{ env.APP }}-${{ env.ENV }}-github-actions