diff --git a/.github/actions/acceptance-tests/action.yaml b/.github/actions/acceptance-tests/action.yaml index 7fe3c28a..6f6a44ba 100644 --- a/.github/actions/acceptance-tests/action.yaml +++ b/.github/actions/acceptance-tests/action.yaml @@ -49,6 +49,7 @@ runs: shell: bash env: PROJECT: nhs - COMPONENT: ${{ inputs.targetComponent }} + COMPONENT: cb + CLIENT_COMPONENT: cbc run: | make test-${{ inputs.testType }} diff --git a/.github/workflows/cicd-1-pull-request.yaml b/.github/workflows/cicd-1-pull-request.yaml index aa5a82bf..a52772a5 100644 --- a/.github/workflows/cicd-1-pull-request.yaml +++ b/.github/workflows/cicd-1-pull-request.yaml @@ -173,6 +173,20 @@ jobs: --overrideProjectName "nhs" \ --overrideRoleName "nhs-main-acct-client-callbacks-github-deploy" \ --overrides "branch_name=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" + - name: Trigger callback-clients dynamic environment creation + shell: bash + run: | + .github/scripts/dispatch_internal_repo_workflow.sh \ + --infraRepoName "$(echo ${{ github.repository }} | cut -d'/' -f2)" \ + --releaseVersion "${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" \ + --targetWorkflow "dispatch-deploy-dynamic-env.yaml" \ + --targetEnvironment "pr${{ needs.metadata.outputs.pr_number }}" \ + --targetComponent "callback-clients" \ + --targetAccountGroup "nhs-notify-client-callbacks-dev" \ + --terraformAction "apply" \ + --overrideProjectName "nhs" \ + --overrideRoleName "nhs-main-acct-client-callbacks-github-deploy" \ + --overrides "branch_name=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" acceptance-stage: # Recommended maximum execution time is 10 minutes name: "Acceptance stage" needs: [metadata, build-stage, pr-create-dynamic-environment] diff --git a/.github/workflows/pr_closed.yml b/.github/workflows/pr_closed.yml index 42e61428..5a7cc82f 100644 --- a/.github/workflows/pr_closed.yml +++ b/.github/workflows/pr_closed.yml @@ -46,7 +46,7 @@ jobs: strategy: max-parallel: 1 matrix: - component: [callbacks] + component: [callbacks, callback-clients] steps: - name: Checkout repository diff --git a/.github/workflows/pr_destroy_dynamic_env.yml b/.github/workflows/pr_destroy_dynamic_env.yml index 67abd292..ccc425b8 100644 --- a/.github/workflows/pr_destroy_dynamic_env.yml +++ b/.github/workflows/pr_destroy_dynamic_env.yml @@ -19,7 +19,23 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Trigger dynamic environment destroy + - name: Trigger callback-clients dynamic environment destroy + env: + APP_PEM_FILE: ${{ secrets.APP_PEM_FILE }} + APP_CLIENT_ID: ${{ secrets.APP_CLIENT_ID }} + shell: bash + run: | + .github/scripts/dispatch_internal_repo_workflow.sh \ + --infraRepoName "$(echo ${{ github.repository }} | cut -d'/' -f2)" \ + --releaseVersion "${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" \ + --targetWorkflow "dispatch-deploy-dynamic-env.yaml" \ + --targetEnvironment "pr${{ github.event.number }}" \ + --targetComponent "callback-clients" \ + --targetAccountGroup "nhs-notify-client-callbacks-dev" \ + --terraformAction "destroy" \ + --overrideProjectName "nhs" \ + --overrideRoleName "nhs-main-acct-client-callbacks-github-deploy" + - name: Trigger callbacks dynamic environment destroy env: APP_PEM_FILE: ${{ secrets.APP_PEM_FILE }} APP_CLIENT_ID: ${{ secrets.APP_CLIENT_ID }} @@ -34,4 +50,4 @@ jobs: --targetAccountGroup "nhs-notify-client-callbacks-dev" \ --terraformAction "destroy" \ --overrideProjectName "nhs" \ - --overrideRoleName "nhs-main-acct-client-callbacks-github-deploy" \ + --overrideRoleName "nhs-main-acct-client-callbacks-github-deploy" diff --git a/.github/workflows/release_created.yml b/.github/workflows/release_created.yml index 329282ae..827eac3e 100644 --- a/.github/workflows/release_created.yml +++ b/.github/workflows/release_created.yml @@ -22,7 +22,7 @@ jobs: strategy: max-parallel: 1 matrix: - component: [callbacks] + component: [callbacks, callback-clients] steps: - name: Checkout repository diff --git a/.tool-versions b/.tool-versions index 4ca52a65..f098ca89 100644 --- a/.tool-versions +++ b/.tool-versions @@ -5,7 +5,7 @@ nodejs 24.14.1 pnpm 10.33.0 pre-commit 3.6.0 ruby 3.3.6 -terraform 1.10.1 +terraform 1.14.3 terraform-docs 0.19.0 #trivy 0.61.0 - TODO - Re-visit Trivy usage https://nhsd-jira.digital.nhs.uk/browse/CCM-15549 vale 3.6.0 diff --git a/infrastructure/terraform/components/callback-clients/.tool-versions b/infrastructure/terraform/components/callback-clients/.tool-versions new file mode 100644 index 00000000..52428ded --- /dev/null +++ b/infrastructure/terraform/components/callback-clients/.tool-versions @@ -0,0 +1 @@ +terraform 1.14.3 diff --git a/infrastructure/terraform/components/callback-clients/README.md b/infrastructure/terraform/components/callback-clients/README.md new file mode 100644 index 00000000..72830609 --- /dev/null +++ b/infrastructure/terraform/components/callback-clients/README.md @@ -0,0 +1,52 @@ + + + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.10.1 | +| [aws](#requirement\_aws) | 6.13 | +| [external](#requirement\_external) | ~> 2.0 | +| [random](#requirement\_random) | ~> 3.0 | +| [tls](#requirement\_tls) | ~> 4.0 | +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [applications\_map\_s3\_bucket](#input\_applications\_map\_s3\_bucket) | S3 bucket for the applications map | `string` | n/a | yes | +| [aws\_account\_id](#input\_aws\_account\_id) | The AWS Account ID (numeric) | `string` | n/a | yes | +| [client\_config\_s3\_bucket](#input\_client\_config\_s3\_bucket) | S3 bucket for client subscription configuration | `string` | n/a | yes | +| [default\_tags](#input\_default\_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no | +| [deploy\_mock\_clients](#input\_deploy\_mock\_clients) | Flag to deploy mock webhook lambda for integration testing | `bool` | `false` | no | +| [enable\_xray\_tracing](#input\_enable\_xray\_tracing) | Enable AWS X-Ray active tracing for Lambda functions | `bool` | `false` | no | +| [environment](#input\_environment) | The name of the tfscaffold environment | `string` | n/a | yes | +| [force\_lambda\_code\_deploy](#input\_force\_lambda\_code\_deploy) | If the lambda package in s3 has the same commit id tag as the terraform build branch, the lambda will not update automatically. Set to True if making changes to Lambda code from on the same commit for example during development | `bool` | `false` | no | +| [group](#input\_group) | The group variables are being inherited from (often synonmous with account short-name) | `string` | n/a | yes | +| [log\_level](#input\_log\_level) | The log level to be used in lambda functions within the component | `string` | `"INFO"` | no | +| [log\_retention\_in\_days](#input\_log\_retention\_in\_days) | The retention period in days for the Cloudwatch Logs events to be retained, default of 0 is indefinite | `number` | `0` | no | +| [mtls\_ca\_s3\_key](#input\_mtls\_ca\_s3\_key) | S3 key for the CA certificate PEM bundle used for server verification | `string` | `""` | no | +| [mtls\_cert\_s3\_bucket](#input\_mtls\_cert\_s3\_bucket) | S3 bucket containing the mTLS client certificate bundle | `string` | `""` | no | +| [mtls\_cert\_s3\_key](#input\_mtls\_cert\_s3\_key) | S3 key for the mTLS client certificate PEM bundle | `string` | `""` | no | +| [parent\_acct\_environment](#input\_parent\_acct\_environment) | Name of the environment responsible for the acct resources used, affects things like DNS zone. Useful for named dev environments | `string` | `"main"` | no | +| [parent\_callbacks\_environment](#input\_parent\_callbacks\_environment) | The name of the environment which deployed the parent callbacks component. Used to identify the appropriate state file. | `string` | `"main"` | no | +| [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes | +| [region](#input\_region) | The AWS Region | `string` | n/a | yes | +| [s3\_enable\_force\_destroy](#input\_s3\_enable\_force\_destroy) | Whether to enable force destroy for the S3 buckets created in this module | `bool` | `false` | no | +| [token\_bucket\_burst\_capacity](#input\_token\_bucket\_burst\_capacity) | Token bucket burst capacity used by the rate limiter | `number` | `2250` | no | +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [client\_delivery](#module\_client\_delivery) | ../../modules/client-delivery | n/a | +| [mock\_webhook\_lambda](#module\_mock\_webhook\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-lambda.zip | n/a | +## Outputs + +| Name | Description | +|------|-------------| +| [applications\_map\_s3](#output\_applications\_map\_s3) | S3 location of the client-to-application map | +| [mock\_webhook\_alb\_dns](#output\_mock\_webhook\_alb\_dns) | DNS name of the mock webhook ALB | + + + diff --git a/infrastructure/terraform/components/callback-clients/locals.tf b/infrastructure/terraform/components/callback-clients/locals.tf new file mode 100644 index 00000000..6fd06208 --- /dev/null +++ b/infrastructure/terraform/components/callback-clients/locals.tf @@ -0,0 +1,62 @@ +locals { + bc_name = "client-callbacks" + aws_lambda_functions_dir_path = "../../../../lambdas" + log_destination_arn = local.callbacks.log_destination_arn + log_subscription_role_arn = local.callbacks.log_subscription_role_arn + + clients_dir_path = "${path.module}/../../modules/clients" + + config_clients = merge([ + for filename in fileset(local.clients_dir_path, "*.json") : { + (replace(filename, ".json", "")) = jsondecode(file("${local.clients_dir_path}/${filename}")) + } + ]...) + + mock_server_spki_hash = var.deploy_mock_clients ? data.external.mock_server_spki_hash[0].result.hash : "" + + enriched_mock_config_clients = var.deploy_mock_clients ? { + for client_id, client in local.config_clients : + client_id => merge(client, { + targets = [ + for target in try(client.targets, []) : + merge(target, { + invocationEndpoint = "https://${aws_lb.mock_webhook_mtls[0].dns_name}/${target.targetId}" + apiKey = merge(target.apiKey, { headerValue = random_password.mock_webhook_api_key[0].result }) + delivery = merge(try(target.delivery, {}), { + mtls = merge(try(target.delivery.mtls, {}), { + certPinning = merge(try(target.delivery.mtls.certPinning, {}), try(target.delivery.mtls.certPinning.enabled, false) ? { + spkiHash = local.mock_server_spki_hash + } : {}) + }) + }) + }) + ] + }) + } : local.config_clients + + client_subscriptions = { + for client_id, data in local.config_clients : + client_id => { + for subscription in try(data.subscriptions, []) : + subscription.subscriptionId => { + subscription_id = subscription.subscriptionId + target_ids = try(subscription.targetIds, []) + } + } + } + + client_subscription_targets = { + for client_id, data in local.config_clients : + client_id => merge([ + for subscription in try(data.subscriptions, []) : { + for target_id in try(subscription.targetIds, []) : + "${subscription.subscriptionId}-${target_id}" => { + subscription_id = subscription.subscriptionId + target_id = target_id + } + } + ]...) + } + + applications_map_s3_key = "${var.environment}/applications-map.json" +} diff --git a/infrastructure/terraform/components/callback-clients/locals_remote_state.tf b/infrastructure/terraform/components/callback-clients/locals_remote_state.tf new file mode 100644 index 00000000..876c2f6c --- /dev/null +++ b/infrastructure/terraform/components/callback-clients/locals_remote_state.tf @@ -0,0 +1,40 @@ +locals { + callbacks = data.terraform_remote_state.callbacks.outputs + acct = data.terraform_remote_state.acct.outputs +} + +data "terraform_remote_state" "callbacks" { + backend = "s3" + + config = { + bucket = local.terraform_state_bucket + + key = format( + "%s/%s/%s/%s/callbacks.tfstate", + var.project, + var.aws_account_id, + "eu-west-2", + var.parent_callbacks_environment + ) + + region = "eu-west-2" + } +} + +data "terraform_remote_state" "acct" { + backend = "s3" + + config = { + bucket = local.terraform_state_bucket + + key = format( + "%s/%s/%s/%s/acct.tfstate", + var.project, + var.aws_account_id, + "eu-west-2", + var.parent_acct_environment + ) + + region = "eu-west-2" + } +} diff --git a/infrastructure/terraform/components/callback-clients/locals_tfscaffold.tf b/infrastructure/terraform/components/callback-clients/locals_tfscaffold.tf new file mode 100644 index 00000000..5c28416c --- /dev/null +++ b/infrastructure/terraform/components/callback-clients/locals_tfscaffold.tf @@ -0,0 +1,46 @@ +locals { + component = "cbc" + + terraform_state_bucket = format( + "%s-tfscaffold-%s-%s", + var.project, + var.aws_account_id, + var.region, + ) + + csi = replace( + format( + "%s-%s-%s", + var.project, + var.environment, + local.component, + ), + "_", + "", + ) + + # CSI for use in resources with a global namespace, i.e. S3 Buckets + csi_global = replace( + format( + "%s-%s-%s-%s-%s", + var.project, + var.aws_account_id, + var.region, + var.environment, + local.component, + ), + "_", + "", + ) + + default_tags = merge( + var.default_tags, + { + Project = var.project + Environment = var.environment + Component = local.component + Group = var.group + Name = local.csi + }, + ) +} diff --git a/infrastructure/terraform/components/callbacks/module_client_delivery.tf b/infrastructure/terraform/components/callback-clients/module_client_delivery.tf similarity index 52% rename from infrastructure/terraform/components/callbacks/module_client_delivery.tf rename to infrastructure/terraform/components/callback-clients/module_client_delivery.tf index cce31bd5..7868338f 100644 --- a/infrastructure/terraform/components/callbacks/module_client_delivery.tf +++ b/infrastructure/terraform/components/callback-clients/module_client_delivery.tf @@ -5,23 +5,25 @@ module "client_delivery" { project = var.project aws_account_id = var.aws_account_id region = var.region - component = var.component + component = local.component environment = var.environment group = var.group client_id = each.key - client_bus_name = aws_cloudwatch_event_bus.main.name - kms_key_arn = module.kms.key_arn + client_bus_name = local.callbacks.eventbus_name.name + kms_key_arn = local.callbacks.kms_key_arn subscriptions = local.client_subscriptions[each.key] subscription_targets = local.client_subscription_targets[each.key] - client_config_bucket = module.client_config_bucket.bucket - client_config_bucket_arn = module.client_config_bucket.arn + client_config_bucket = var.client_config_s3_bucket + client_config_bucket_arn = local.callbacks.client_config_bucket.arn + client_config_key_prefix = "${var.environment}/client_subscriptions/" - applications_map_parameter_name = local.applications_map_parameter_name + applications_map_s3_bucket = var.applications_map_s3_bucket + applications_map_s3_key = local.applications_map_s3_key - lambda_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"] + lambda_s3_bucket = local.callbacks.lambda_s3_bucket lambda_code_base_path = local.aws_lambda_functions_dir_path force_lambda_code_deploy = var.force_lambda_code_deploy @@ -30,11 +32,11 @@ module "client_delivery" { enable_xray_tracing = var.enable_xray_tracing log_destination_arn = local.log_destination_arn - log_subscription_role_arn = local.acct.log_subscription_role_arn + log_subscription_role_arn = local.log_subscription_role_arn - elasticache_endpoint = aws_elasticache_serverless_cache.delivery_state.endpoint[0].address - elasticache_cache_name = aws_elasticache_serverless_cache.delivery_state.name - elasticache_iam_username = "${var.project}-${var.environment}-${var.component}-elasticache-user" + elasticache_endpoint = local.callbacks.elasticache.endpoint + elasticache_cache_name = local.callbacks.elasticache.cache_name + elasticache_iam_username = local.callbacks.elasticache.iam_username mtls_cert_s3_bucket = local.mtls_cert_s3_bucket mtls_cert_s3_key = local.mtls_cert_s3_key # gitleaks:allow @@ -42,6 +44,6 @@ module "client_delivery" { token_bucket_burst_capacity = var.token_bucket_burst_capacity - vpc_subnet_ids = try(local.acct.private_subnets[local.bc_name], []) - lambda_security_group_id = aws_security_group.https_client_lambda.id + vpc_subnet_ids = local.callbacks.vpc_subnet_ids + lambda_security_group_id = local.callbacks.security_group_id } diff --git a/infrastructure/terraform/components/callbacks/module_mock_webhook_alb_mtls.tf b/infrastructure/terraform/components/callback-clients/module_mock_webhook_alb_mtls.tf similarity index 92% rename from infrastructure/terraform/components/callbacks/module_mock_webhook_alb_mtls.tf rename to infrastructure/terraform/components/callback-clients/module_mock_webhook_alb_mtls.tf index eb8b6776..bf3c2698 100644 --- a/infrastructure/terraform/components/callbacks/module_mock_webhook_alb_mtls.tf +++ b/infrastructure/terraform/components/callback-clients/module_mock_webhook_alb_mtls.tf @@ -15,7 +15,7 @@ resource "aws_security_group" "mock_webhook_alb" { resource "aws_vpc_security_group_ingress_rule" "mock_webhook_alb_https" { count = var.deploy_mock_clients ? 1 : 0 security_group_id = aws_security_group.mock_webhook_alb[0].id - referenced_security_group_id = aws_security_group.https_client_lambda.id + referenced_security_group_id = local.callbacks.security_group_id from_port = 443 to_port = 443 ip_protocol = "tcp" @@ -41,17 +41,17 @@ resource "aws_acm_certificate" "mock_webhook_server" { resource "aws_lb" "mock_webhook_mtls" { count = var.deploy_mock_clients ? 1 : 0 - name = substr("${local.csi}-mock-mtls", 0, 32) + name = "${local.csi}-mock-mtls" internal = true load_balancer_type = "application" security_groups = [aws_security_group.mock_webhook_alb[0].id] - subnets = try(local.acct.private_subnets[local.bc_name], []) + subnets = local.callbacks.vpc_subnet_ids tags = local.default_tags } resource "aws_lb_target_group" "mock_webhook_mtls" { count = var.deploy_mock_clients ? 1 : 0 - name = substr("${local.csi}-mock-mtls", 0, 32) + name = "${local.csi}-mock-mtls" target_type = "lambda" tags = local.default_tags } diff --git a/infrastructure/terraform/components/callbacks/module_mock_webhook_lambda.tf b/infrastructure/terraform/components/callback-clients/module_mock_webhook_lambda.tf similarity index 86% rename from infrastructure/terraform/components/callbacks/module_mock_webhook_lambda.tf rename to infrastructure/terraform/components/callback-clients/module_mock_webhook_lambda.tf index 467dc1c6..786d6fe9 100644 --- a/infrastructure/terraform/components/callbacks/module_mock_webhook_lambda.tf +++ b/infrastructure/terraform/components/callback-clients/module_mock_webhook_lambda.tf @@ -6,20 +6,20 @@ module "mock_webhook_lambda" { description = "Mock webhook endpoint for integration testing - logs received callbacks to CloudWatch" aws_account_id = var.aws_account_id - component = var.component + component = local.component environment = var.environment project = var.project region = var.region group = var.group log_retention_in_days = var.log_retention_in_days - kms_key_arn = module.kms.key_arn + kms_key_arn = local.callbacks.kms_key_arn iam_policy_document = { body = data.aws_iam_policy_document.mock_webhook_lambda[0].json } - function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"] + function_s3_bucket = local.callbacks.lambda_s3_bucket function_code_base_path = local.aws_lambda_functions_dir_path function_code_dir = "mock-webhook-lambda/dist" function_include_common = true @@ -33,7 +33,7 @@ module "mock_webhook_lambda" { enable_lambda_insights = false log_destination_arn = local.log_destination_arn - log_subscription_role_arn = local.acct.log_subscription_role_arn + log_subscription_role_arn = local.log_subscription_role_arn lambda_env_vars = { LOG_LEVEL = var.log_level @@ -60,7 +60,7 @@ data "aws_iam_policy_document" "mock_webhook_lambda" { ] resources = [ - module.kms.key_arn, + local.callbacks.kms_key_arn, ] } } diff --git a/infrastructure/terraform/components/callback-clients/outputs.tf b/infrastructure/terraform/components/callback-clients/outputs.tf new file mode 100644 index 00000000..2651b9c5 --- /dev/null +++ b/infrastructure/terraform/components/callback-clients/outputs.tf @@ -0,0 +1,12 @@ +output "mock_webhook_alb_dns" { + description = "DNS name of the mock webhook ALB" + value = var.deploy_mock_clients ? aws_lb.mock_webhook_mtls[0].dns_name : null +} + +output "applications_map_s3" { + description = "S3 location of the client-to-application map" + value = { + bucket = var.applications_map_s3_bucket + key = local.applications_map_s3_key + } +} diff --git a/infrastructure/terraform/components/callback-clients/pre.sh b/infrastructure/terraform/components/callback-clients/pre.sh new file mode 100644 index 00000000..918f9bb1 --- /dev/null +++ b/infrastructure/terraform/components/callback-clients/pre.sh @@ -0,0 +1,44 @@ +# This script is run before Terraform apply for the callback-clients component. +# It installs dependencies, syncs client config from S3 into modules/clients/, +# copies mock/perf fixtures when needed, and builds lambda workspaces. + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/../../../.." && pwd)" +clients_dir="${repo_root}/infrastructure/terraform/modules/clients" + +_real_script="$(readlink -f "${BASH_SOURCE[0]}")" +bounded_context_root="$(cd "$(dirname "${_real_script}")/../../../.." && pwd)" + +# Resolve deploy_mock_clients and deploy_perf_runner from tfvars +deploy_mock_clients="false" +deploy_perf_runner="false" +for _tfvar_file in \ + "${base_path}/etc/group_${group}.tfvars" \ + "${base_path}/etc/env_${region}_${environment}.tfvars"; do + if [[ -f "${_tfvar_file}" ]]; then + _val=$(grep -E '^\s*deploy_mock_clients\s*=' "${_tfvar_file}" | tail -1 | sed 's/.*=\s*//;s/\s*$//') + [ -n "${_val}" ] && deploy_mock_clients="${_val}" + _val=$(grep -E '^\s*deploy_perf_runner\s*=' "${_tfvar_file}" | tail -1 | sed 's/.*=\s*//;s/\s*$//') + [ -n "${_val}" ] && deploy_perf_runner="${_val}" + fi +done +echo "deploy_mock_clients resolved to: ${deploy_mock_clients}" +echo "deploy_perf_runner resolved to: ${deploy_perf_runner}" + +pnpm install --frozen-lockfile + +pnpm run generate-dependencies + +"${script_dir}/../callbacks/sync-client-config.sh" + +if [ "${deploy_mock_clients}" == "true" ]; then + cp "${bounded_context_root}/tests/integration/fixtures/subscriptions/"*.json "${clients_dir}/" + echo "Copied mock client subscription config fixtures into clients dir" +fi + +if [ "${deploy_perf_runner}" == "true" ]; then + cp "${bounded_context_root}/tests/performance/fixtures/subscriptions/"*.json "${clients_dir}/" + echo "Copied perf client subscription config fixtures into clients dir" +fi + +pnpm run --recursive --if-present lambda-build diff --git a/infrastructure/terraform/components/callback-clients/provider_aws.tf b/infrastructure/terraform/components/callback-clients/provider_aws.tf new file mode 100644 index 00000000..d694811e --- /dev/null +++ b/infrastructure/terraform/components/callback-clients/provider_aws.tf @@ -0,0 +1,24 @@ +provider "aws" { + region = var.region + + allowed_account_ids = [ + var.aws_account_id, + ] + + default_tags { + tags = local.default_tags + } +} + +provider "aws" { + alias = "us-east-1" + region = "us-east-1" + + default_tags { + tags = local.default_tags + } + + allowed_account_ids = [ + var.aws_account_id, + ] +} diff --git a/infrastructure/terraform/components/callbacks/s3_bucket_mtls_test_certs.tf b/infrastructure/terraform/components/callback-clients/s3_bucket_mtls_test_certs.tf similarity index 67% rename from infrastructure/terraform/components/callbacks/s3_bucket_mtls_test_certs.tf rename to infrastructure/terraform/components/callback-clients/s3_bucket_mtls_test_certs.tf index c29806cd..3594b016 100644 --- a/infrastructure/terraform/components/callbacks/s3_bucket_mtls_test_certs.tf +++ b/infrastructure/terraform/components/callback-clients/s3_bucket_mtls_test_certs.tf @@ -1,67 +1,8 @@ -module "mtls_test_certs_bucket" { - count = var.deploy_mock_clients ? 1 : 0 - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-s3bucket.zip" - - name = "mtls-test-certs" - - aws_account_id = var.aws_account_id - component = var.component - environment = var.environment - project = var.project - region = var.region - - default_tags = merge( - local.default_tags, - { - Description = "mTLS test certificate material for non-production callback delivery" - } - ) - - kms_key_arn = module.kms.key_arn - force_destroy = var.s3_enable_force_destroy - versioning = false - object_ownership = "BucketOwnerPreferred" - bucket_key_enabled = true - - policy_documents = [ - data.aws_iam_policy_document.mtls_test_certs_bucket[0].json - ] -} - -data "aws_iam_policy_document" "mtls_test_certs_bucket" { - count = var.deploy_mock_clients ? 1 : 0 - - statement { - sid = "DenyInsecureTransport" - effect = "Deny" - - principals { - type = "*" - identifiers = ["*"] - } - - actions = [ - "s3:*", - ] - - resources = [ - "arn:aws:s3:::${var.project}-${var.aws_account_id}-${var.region}-${var.environment}-${var.component}-mtls-test-certs", - "arn:aws:s3:::${var.project}-${var.aws_account_id}-${var.region}-${var.environment}-${var.component}-mtls-test-certs/*" - ] - - condition { - test = "Bool" - variable = "aws:SecureTransport" - values = ["false"] - } - } -} - locals { mtls_test_certs_s3_prefix = "callbacks/mtls-test" mtls_test_cert_s3_key = "${local.mtls_test_certs_s3_prefix}/client-bundle.pem" mtls_test_ca_s3_key = "${local.mtls_test_certs_s3_prefix}/ca.pem" - mtls_cert_s3_bucket = var.deploy_mock_clients ? module.mtls_test_certs_bucket[0].bucket : var.mtls_cert_s3_bucket + mtls_cert_s3_bucket = var.mtls_cert_s3_bucket mtls_cert_s3_key = var.deploy_mock_clients ? local.mtls_test_cert_s3_key : var.mtls_cert_s3_key # gitleaks:allow mtls_ca_s3_key = var.deploy_mock_clients ? local.mtls_test_ca_s3_key : var.mtls_ca_s3_key # gitleaks:allow } @@ -158,22 +99,22 @@ resource "tls_locally_signed_cert" "mock_server" { resource "aws_s3_object" "mtls_test_client_bundle" { count = var.deploy_mock_clients ? 1 : 0 - bucket = module.mtls_test_certs_bucket[0].id + bucket = var.mtls_cert_s3_bucket key = local.mtls_test_cert_s3_key # gitleaks:allow content = "${tls_locally_signed_cert.test_client[0].cert_pem}${tls_private_key.test_client[0].private_key_pem}" - server_side_encryption = "aws:kms" - content_type = "application/x-pem-file" + kms_key_id = local.callbacks.kms_key_arn + content_type = "application/x-pem-file" } resource "aws_s3_object" "mtls_test_ca" { count = var.deploy_mock_clients ? 1 : 0 - bucket = module.mtls_test_certs_bucket[0].id + bucket = var.mtls_cert_s3_bucket key = local.mtls_test_ca_s3_key # gitleaks:allow content = tls_self_signed_cert.test_ca[0].cert_pem - server_side_encryption = "aws:kms" - content_type = "application/x-pem-file" + kms_key_id = local.callbacks.kms_key_arn + content_type = "application/x-pem-file" } # Compute the base64-encoded SHA-256 hash of the mock server's SPKI (Subject Public Key Info) DER. diff --git a/infrastructure/terraform/components/callback-clients/s3_client_config_objects.tf b/infrastructure/terraform/components/callback-clients/s3_client_config_objects.tf new file mode 100644 index 00000000..afca6106 --- /dev/null +++ b/infrastructure/terraform/components/callback-clients/s3_client_config_objects.tf @@ -0,0 +1,9 @@ +resource "aws_s3_object" "mock_client_config" { + for_each = var.deploy_mock_clients ? local.enriched_mock_config_clients : {} + + bucket = var.client_config_s3_bucket + key = "${var.environment}/client_subscriptions/${each.key}.json" + content = jsonencode(each.value) + content_type = "application/json" + kms_key_id = local.callbacks.kms_key_arn +} diff --git a/infrastructure/terraform/components/callback-clients/s3_object_applications_map.tf b/infrastructure/terraform/components/callback-clients/s3_object_applications_map.tf new file mode 100644 index 00000000..ee0428e8 --- /dev/null +++ b/infrastructure/terraform/components/callback-clients/s3_object_applications_map.tf @@ -0,0 +1,9 @@ +resource "aws_s3_object" "applications_map" { + count = var.deploy_mock_clients ? 1 : 0 + + bucket = var.applications_map_s3_bucket + key = local.applications_map_s3_key + content = jsonencode({ for client_id, client in local.config_clients : client_id => try(client.applicationId, client_id) }) + content_type = "application/json" + kms_key_id = local.callbacks.kms_key_arn +} diff --git a/infrastructure/terraform/components/callback-clients/variables.tf b/infrastructure/terraform/components/callback-clients/variables.tf new file mode 100644 index 00000000..e7f72997 --- /dev/null +++ b/infrastructure/terraform/components/callback-clients/variables.tf @@ -0,0 +1,124 @@ +## +# Basic Required Variables for tfscaffold Components +## + +variable "project" { + type = string + description = "The name of the tfscaffold project" +} + +variable "environment" { + type = string + description = "The name of the tfscaffold environment" +} + +variable "aws_account_id" { + type = string + description = "The AWS Account ID (numeric)" +} + +variable "region" { + type = string + description = "The AWS Region" +} + +variable "group" { + type = string + description = "The group variables are being inherited from (often synonmous with account short-name)" +} + +## +# tfscaffold variables specific to this component +## + +variable "default_tags" { + type = map(string) + description = "A map of default tags to apply to all taggable resources within the component" + default = {} +} + +variable "parent_acct_environment" { + type = string + description = "Name of the environment responsible for the acct resources used, affects things like DNS zone. Useful for named dev environments" + default = "main" +} + +variable "parent_callbacks_environment" { + type = string + description = "The name of the environment which deployed the parent callbacks component. Used to identify the appropriate state file." + default = "main" +} + +## +# Variables specific to the component +## + +variable "log_retention_in_days" { + type = number + description = "The retention period in days for the Cloudwatch Logs events to be retained, default of 0 is indefinite" + default = 0 +} + +variable "log_level" { + type = string + description = "The log level to be used in lambda functions within the component" + default = "INFO" +} + +variable "force_lambda_code_deploy" { + type = bool + description = "If the lambda package in s3 has the same commit id tag as the terraform build branch, the lambda will not update automatically. Set to True if making changes to Lambda code from on the same commit for example during development" + default = false +} + +variable "deploy_mock_clients" { + type = bool + description = "Flag to deploy mock webhook lambda for integration testing" + default = false +} + +variable "enable_xray_tracing" { + type = bool + description = "Enable AWS X-Ray active tracing for Lambda functions" + default = false +} + +variable "token_bucket_burst_capacity" { + type = number + description = "Token bucket burst capacity used by the rate limiter" + default = 2250 +} + +variable "s3_enable_force_destroy" { + type = bool + description = "Whether to enable force destroy for the S3 buckets created in this module" + default = false +} + +variable "mtls_cert_s3_bucket" { + type = string + description = "S3 bucket containing the mTLS client certificate bundle" + default = "" +} + +variable "mtls_cert_s3_key" { + type = string + description = "S3 key for the mTLS client certificate PEM bundle" + default = "" +} + +variable "mtls_ca_s3_key" { + type = string + description = "S3 key for the CA certificate PEM bundle used for server verification" + default = "" +} + +variable "applications_map_s3_bucket" { + type = string + description = "S3 bucket for the applications map" +} + +variable "client_config_s3_bucket" { + type = string + description = "S3 bucket for client subscription configuration" +} diff --git a/infrastructure/terraform/components/callback-clients/versions.tf b/infrastructure/terraform/components/callback-clients/versions.tf new file mode 100644 index 00000000..d91998a2 --- /dev/null +++ b/infrastructure/terraform/components/callback-clients/versions.tf @@ -0,0 +1,22 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "6.13" + } + external = { + source = "hashicorp/external" + version = "~> 2.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + tls = { + source = "hashicorp/tls" + version = "~> 4.0" + } + } + + required_version = ">= 1.10.1" +} diff --git a/infrastructure/terraform/components/callbacks/.tool-versions b/infrastructure/terraform/components/callbacks/.tool-versions index 3dd74c72..52428ded 100644 --- a/infrastructure/terraform/components/callbacks/.tool-versions +++ b/infrastructure/terraform/components/callbacks/.tool-versions @@ -1 +1 @@ -terraform 1.10.1 +terraform 1.14.3 diff --git a/infrastructure/terraform/components/callbacks/README.md b/infrastructure/terraform/components/callbacks/README.md index 9889ab22..fdb941d9 100644 --- a/infrastructure/terraform/components/callbacks/README.md +++ b/infrastructure/terraform/components/callbacks/README.md @@ -8,19 +8,16 @@ |------|---------| | [terraform](#requirement\_terraform) | >= 1.10.1 | | [aws](#requirement\_aws) | 6.13 | -| [external](#requirement\_external) | ~> 2.0 | | [random](#requirement\_random) | ~> 3.0 | -| [tls](#requirement\_tls) | ~> 4.0 | ## Inputs | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [applications\_map\_parameter\_name](#input\_applications\_map\_parameter\_name) | SSM Parameter Store path for the clientId-to-applicationData map, where applicationData is currently only the applicationId | `string` | `null` | no | | [aws\_account\_id](#input\_aws\_account\_id) | The AWS Account ID (numeric) | `string` | n/a | yes | -| [component](#input\_component) | The variable encapsulating the name of this component | `string` | `"callbacks"` | no | +| [client\_config\_s3\_bucket](#input\_client\_config\_s3\_bucket) | S3 bucket for client subscription configuration | `string` | n/a | yes | | [default\_tags](#input\_default\_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no | -| [deploy\_mock\_clients](#input\_deploy\_mock\_clients) | Flag to deploy mock webhook lambda for integration testing (test/dev environments only) | `bool` | `false` | no | -| [deploy\_perf\_runner](#input\_deploy\_perf\_runner) | Flag to deploy the perf-runner lambda for performance testing (test/dev environments only) | `bool` | `false` | no | +| [deploy\_mock\_clients](#input\_deploy\_mock\_clients) | Flag indicating whether mock clients are deployed in callback-clients component (used by perf runner for log group references) | `bool` | `false` | no | +| [deploy\_perf\_runner](#input\_deploy\_perf\_runner) | Flag to deploy the perf-runner lambda for performance testing | `bool` | `false` | no | | [elasticache\_data\_storage\_maximum\_gb](#input\_elasticache\_data\_storage\_maximum\_gb) | Maximum data storage in GB for the ElastiCache Serverless delivery state cache | `number` | `1` | no | | [enable\_event\_anomaly\_detection](#input\_enable\_event\_anomaly\_detection) | Enable CloudWatch anomaly detection alarm for inbound event queue message reception | `bool` | `true` | no | | [enable\_xray\_tracing](#input\_enable\_xray\_tracing) | Enable AWS X-Ray active tracing for Lambda functions | `bool` | `false` | no | @@ -34,9 +31,6 @@ | [log\_level](#input\_log\_level) | The log level to be used in lambda functions within the component. Any log with a lower severity than the configured value will not be logged: https://docs.python.org/3/library/logging.html#levels | `string` | `"INFO"` | no | | [log\_retention\_in\_days](#input\_log\_retention\_in\_days) | The retention period in days for the Cloudwatch Logs events to be retained, default of 0 is indefinite | `number` | `0` | no | | [message\_root\_uri](#input\_message\_root\_uri) | The root URI used for constructing message links in callback payloads | `string` | n/a | yes | -| [mtls\_ca\_s3\_key](#input\_mtls\_ca\_s3\_key) | S3 key for the CA certificate PEM bundle used for server verification | `string` | `""` | no | -| [mtls\_cert\_s3\_bucket](#input\_mtls\_cert\_s3\_bucket) | S3 bucket containing the mTLS client certificate bundle | `string` | `""` | no | -| [mtls\_cert\_s3\_key](#input\_mtls\_cert\_s3\_key) | S3 key for the mTLS client certificate PEM bundle | `string` | `""` | no | | [parent\_acct\_environment](#input\_parent\_acct\_environment) | Name of the environment responsible for the acct resources used, affects things like DNS zone. Useful for named dev environments | `string` | `"main"` | no | | [pipe\_event\_patterns](#input\_pipe\_event\_patterns) | value | `list(string)` | `[]` | no | | [pipe\_log\_level](#input\_pipe\_log\_level) | Log level for the EventBridge Pipe. | `string` | `"ERROR"` | no | @@ -44,28 +38,30 @@ | [pipe\_sqs\_max\_batch\_window](#input\_pipe\_sqs\_max\_batch\_window) | n/a | `number` | `2` | no | | [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes | | [region](#input\_region) | The AWS Region | `string` | n/a | yes | -| [s3\_enable\_force\_destroy](#input\_s3\_enable\_force\_destroy) | Whether to enable force destroy for the S3 buckets created in this module | `bool` | `false` | no | | [sqs\_inbound\_event\_max\_receive\_count](#input\_sqs\_inbound\_event\_max\_receive\_count) | n/a | `number` | `3` | no | | [sqs\_inbound\_event\_visibility\_timeout\_seconds](#input\_sqs\_inbound\_event\_visibility\_timeout\_seconds) | n/a | `number` | `60` | no | -| [token\_bucket\_burst\_capacity](#input\_token\_bucket\_burst\_capacity) | Token bucket burst capacity used by the rate limiter | `number` | `2250` | no | ## Modules | Name | Source | Version | |------|--------|---------| -| [client\_config\_bucket](#module\_client\_config\_bucket) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-s3bucket.zip | n/a | -| [client\_delivery](#module\_client\_delivery) | ../../modules/client-delivery | n/a | | [client\_transform\_filter\_lambda](#module\_client\_transform\_filter\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-lambda.zip | n/a | | [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-kms.zip | n/a | -| [mock\_webhook\_lambda](#module\_mock\_webhook\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-lambda.zip | n/a | -| [mtls\_test\_certs\_bucket](#module\_mtls\_test\_certs\_bucket) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-s3bucket.zip | n/a | | [perf\_runner\_lambda](#module\_perf\_runner\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-lambda.zip | n/a | | [sqs\_inbound\_event](#module\_sqs\_inbound\_event) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-sqs.zip | n/a | ## Outputs | Name | Description | |------|-------------| +| [client\_config\_bucket](#output\_client\_config\_bucket) | S3 bucket for client subscription configuration | | [deployment](#output\_deployment) | Deployment details used for post-deployment scripts | -| [mock\_webhook\_lambda\_log\_group\_name](#output\_mock\_webhook\_lambda\_log\_group\_name) | CloudWatch log group name for mock webhook lambda (for integration test queries) | +| [elasticache](#output\_elasticache) | ElastiCache delivery state details for cross-component access | +| [eventbus\_name](#output\_eventbus\_name) | Name of the EventBridge event bus for callback events | +| [kms\_key\_arn](#output\_kms\_key\_arn) | ARN of the KMS key used for encryption in the callbacks component | +| [lambda\_s3\_bucket](#output\_lambda\_s3\_bucket) | S3 bucket ID for Lambda function artefacts | +| [log\_destination\_arn](#output\_log\_destination\_arn) | Firehose destination ARN for log forwarding | +| [log\_subscription\_role\_arn](#output\_log\_subscription\_role\_arn) | IAM role ARN for CloudWatch log subscription | +| [security\_group\_id](#output\_security\_group\_id) | Security group ID for per-client HTTPS delivery Lambda functions | +| [vpc\_subnet\_ids](#output\_vpc\_subnet\_ids) | Private subnet IDs for Lambda VPC configuration | diff --git a/infrastructure/terraform/components/callbacks/_paths.sh b/infrastructure/terraform/components/callbacks/_paths.sh deleted file mode 100644 index 9b9aba00..00000000 --- a/infrastructure/terraform/components/callbacks/_paths.sh +++ /dev/null @@ -1,8 +0,0 @@ -_paths_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -repo_root="$(cd "${_paths_dir}/../../../.." && pwd)" -clients_dir="${repo_root}/infrastructure/terraform/modules/clients" - -# Follow symlinks to find the real nhs-notify-client-callbacks root -# (repo_root resolves to the workspace root, which differs in CI where the component is symlinked in) -_real_script="$(readlink -f "${BASH_SOURCE[0]}")" -bounded_context_root="$(cd "$(dirname "${_real_script}")/../../../.." && pwd)" diff --git a/infrastructure/terraform/components/callbacks/locals.tf b/infrastructure/terraform/components/callbacks/locals.tf index 68129a5b..0f4ce15d 100644 --- a/infrastructure/terraform/components/callbacks/locals.tf +++ b/infrastructure/terraform/components/callbacks/locals.tf @@ -4,67 +4,5 @@ locals { log_destination_arn = "arn:aws:firehose:${var.region}:${var.aws_account_id}:deliverystream/nhs-main-obs-splunk-logs-firehose" root_domain_name = "${var.environment}.${local.acct.route53_zone_names["client-callbacks"]}" # e.g. [main|dev|abxy0].smsnudge.[dev|nonprod|prod].nhsnotify.national.nhs.uk root_domain_id = local.acct.route53_zone_ids["client-callbacks"] - - clients_dir_path = "${path.module}/../../modules/clients" - - config_clients = merge([ - for filename in fileset(local.clients_dir_path, "*.json") : { - (replace(filename, ".json", "")) = jsondecode(file("${local.clients_dir_path}/${filename}")) - } - ]...) - - # SPKI hash of the mock webhook server certificate for cert-pinning enrichment. - # Computed via external data source because Terraform cannot SHA-256 hash raw binary (DER) data natively. - mock_server_spki_hash = var.deploy_mock_clients ? data.external.mock_server_spki_hash[0].result.hash : "" - - # When deploying mock clients, replace sentinel placeholder values with the mock webhook URL and API key. - # Only used for S3 object content — must not be used as a for_each source (contains apply-time values). - enriched_mock_config_clients = var.deploy_mock_clients ? { - for client_id, client in local.config_clients : - client_id => merge(client, { - targets = [ - for target in try(client.targets, []) : - merge(target, { - invocationEndpoint = "https://${aws_lb.mock_webhook_mtls[0].dns_name}/${target.targetId}" - apiKey = merge(target.apiKey, { headerValue = random_password.mock_webhook_api_key[0].result }) - delivery = merge(try(target.delivery, {}), { - mtls = merge(try(target.delivery.mtls, {}), { - certPinning = merge(try(target.delivery.mtls.certPinning, {}), try(target.delivery.mtls.certPinning.enabled, false) ? { - spkiHash = local.mock_server_spki_hash - } : {}) - }) - }) - }) - ] - }) - } : local.config_clients - - - client_subscriptions = { - for client_id, data in local.config_clients : - client_id => { - for subscription in try(data.subscriptions, []) : - subscription.subscriptionId => { - subscription_id = subscription.subscriptionId - target_ids = try(subscription.targetIds, []) - } - } - } - - client_subscription_targets = { - for client_id, data in local.config_clients : - client_id => merge([ - for subscription in try(data.subscriptions, []) : { - for target_id in try(subscription.targetIds, []) : - "${subscription.subscriptionId}-${target_id}" => { - subscription_id = subscription.subscriptionId - target_id = target_id - } - } - ]...) - } - - applications_map_parameter_name = coalesce(var.applications_map_parameter_name, "/${var.project}/${var.environment}/${var.component}/applications-map") - - client_config_bucket_arn = "arn:aws:s3:::${var.project}-${var.aws_account_id}-${var.region}-${var.environment}-${var.component}-subscription-config" + client_config_bucket_arn = "arn:aws:s3:::${var.client_config_s3_bucket}" } diff --git a/infrastructure/terraform/components/callbacks/locals_tfscaffold.tf b/infrastructure/terraform/components/callbacks/locals_tfscaffold.tf index b7cf3217..4d68787a 100644 --- a/infrastructure/terraform/components/callbacks/locals_tfscaffold.tf +++ b/infrastructure/terraform/components/callbacks/locals_tfscaffold.tf @@ -1,4 +1,6 @@ locals { + component = "cb" + terraform_state_bucket = format( "%s-tfscaffold-%s-%s", var.project, @@ -11,7 +13,7 @@ locals { "%s-%s-%s", var.project, var.environment, - var.component, + local.component, ), "_", "", @@ -25,7 +27,7 @@ locals { var.aws_account_id, var.region, var.environment, - var.component, + local.component, ), "_", "", @@ -36,7 +38,7 @@ locals { { Project = var.project Environment = var.environment - Component = var.component + Component = local.component Group = var.group Name = local.csi }, diff --git a/infrastructure/terraform/components/callbacks/module_kms.tf b/infrastructure/terraform/components/callbacks/module_kms.tf index 327b5641..1cf7b03a 100644 --- a/infrastructure/terraform/components/callbacks/module_kms.tf +++ b/infrastructure/terraform/components/callbacks/module_kms.tf @@ -2,7 +2,7 @@ module "kms" { source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-kms.zip" aws_account_id = var.aws_account_id - component = var.component + component = local.component environment = var.environment project = var.project region = var.region @@ -64,9 +64,10 @@ data "aws_iam_policy_document" "kms" { test = "ArnLike" variable = "kms:EncryptionContext:aws:sqs:arn" values = [ - "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-callbacks-inbound-event-queue", - "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-callbacks-inbound-event-dlq", - "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-callbacks-*-dlq-queue" #wildcard here so that DLQs for clients can also use this key + "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-cb-inbound-event-queue", + "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-cb-inbound-event-dlq", + "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-cbc-*-delivery-queue", + "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-cbc-*-delivery-dlq-queue", ] } } diff --git a/infrastructure/terraform/components/callbacks/module_perf_runner_lambda.tf b/infrastructure/terraform/components/callbacks/module_perf_runner_lambda.tf index 7a77c40c..1af65e48 100644 --- a/infrastructure/terraform/components/callbacks/module_perf_runner_lambda.tf +++ b/infrastructure/terraform/components/callbacks/module_perf_runner_lambda.tf @@ -6,7 +6,7 @@ module "perf_runner_lambda" { description = "Lambda function that executes performance tests against the client callbacks pipeline from within AWS" aws_account_id = var.aws_account_id - component = var.component + component = local.component environment = var.environment project = var.project region = var.region @@ -39,11 +39,12 @@ module "perf_runner_lambda" { ENVIRONMENT = var.environment INBOUND_QUEUE_URL = module.sqs_inbound_event.sqs_queue_url TRANSFORM_FILTER_LOG_GROUP = module.client_transform_filter_lambda.cloudwatch_log_group_name - DELIVERY_LOG_GROUP_PREFIX = "/aws/lambda/${local.csi}-https-client-" - MOCK_WEBHOOK_LOG_GROUP = var.deploy_mock_clients ? module.mock_webhook_lambda[0].cloudwatch_log_group_name : "" + DELIVERY_LOG_GROUP_PREFIX = "/aws/lambda/${var.project}-${var.environment}-cbc-https-client-" + DELIVERY_QUEUE_URL_PREFIX = "https://sqs.${var.region}.amazonaws.com/${var.aws_account_id}/${var.project}-${var.environment}-cbc-" + MOCK_WEBHOOK_LOG_GROUP = var.deploy_mock_clients ? "/aws/lambda/${var.project}-${var.environment}-cbc-mock-webhook" : "" ELASTICACHE_ENDPOINT = aws_elasticache_serverless_cache.delivery_state.endpoint[0].address ELASTICACHE_CACHE_NAME = aws_elasticache_serverless_cache.delivery_state.name - ELASTICACHE_IAM_USERNAME = "${var.project}-${var.environment}-${var.component}-elasticache-user" + ELASTICACHE_IAM_USERNAME = "${var.project}-${var.environment}-${local.component}-elasticache-user" } vpc_config = { @@ -114,7 +115,7 @@ data "aws_iam_policy_document" "perf_runner_lambda" { "arn:aws:logs:${var.region}:${var.aws_account_id}:log-group:/aws/lambda/${local.csi}-https-client-*", ], var.deploy_mock_clients ? [ - "arn:aws:logs:${var.region}:${var.aws_account_id}:log-group:${module.mock_webhook_lambda[0].cloudwatch_log_group_name}:*", + "arn:aws:logs:${var.region}:${var.aws_account_id}:log-group:/aws/lambda/${var.project}-${var.environment}-cbc-mock-webhook:*", ] : [], ) } diff --git a/infrastructure/terraform/components/callbacks/module_sqs_inbound_event.tf b/infrastructure/terraform/components/callbacks/module_sqs_inbound_event.tf index 2e3080fe..2a15e357 100644 --- a/infrastructure/terraform/components/callbacks/module_sqs_inbound_event.tf +++ b/infrastructure/terraform/components/callbacks/module_sqs_inbound_event.tf @@ -2,7 +2,7 @@ module "sqs_inbound_event" { source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-sqs.zip" aws_account_id = var.aws_account_id - component = var.component + component = local.component environment = var.environment project = var.project region = var.region @@ -33,7 +33,7 @@ data "aws_iam_policy_document" "sqs_inbound_event" { ] resources = [ - "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-${var.component}-inbound-event-queue" + "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-${local.component}-inbound-event-queue" ] condition { diff --git a/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf b/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf index 2b75ddd5..7a327712 100644 --- a/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf +++ b/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf @@ -5,7 +5,7 @@ module "client_transform_filter_lambda" { description = "Lambda function that transforms and filters events coming to through the eventpipe" aws_account_id = var.aws_account_id - component = var.component + component = local.component environment = var.environment project = var.project region = var.region @@ -38,8 +38,8 @@ module "client_transform_filter_lambda" { lambda_env_vars = { ENVIRONMENT = var.environment METRICS_NAMESPACE = "nhs-notify-client-callbacks" - CLIENT_SUBSCRIPTION_CONFIG_BUCKET = module.client_config_bucket.bucket - CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "client_subscriptions/" + CLIENT_SUBSCRIPTION_CONFIG_BUCKET = var.client_config_s3_bucket + CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "${var.environment}/client_subscriptions/" CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS = "60" MESSAGE_ROOT_URI = var.message_root_uri } diff --git a/infrastructure/terraform/components/callbacks/outputs.tf b/infrastructure/terraform/components/callbacks/outputs.tf index 1ca00df8..257a4084 100644 --- a/infrastructure/terraform/components/callbacks/outputs.tf +++ b/infrastructure/terraform/components/callbacks/outputs.tf @@ -10,15 +10,73 @@ output "deployment" { project = var.project environment = var.environment group = var.group - component = var.component + component = local.component } } ## -# Mock Webhook Lambda Outputs (test/dev environments only). +# EventBridge Event Bus Outputs ## -output "mock_webhook_lambda_log_group_name" { - description = "CloudWatch log group name for mock webhook lambda (for integration test queries)" - value = var.deploy_mock_clients ? module.mock_webhook_lambda[0].cloudwatch_log_group_name : null +output "eventbus_name" { + description = "Name of the EventBridge event bus for callback events" + value = { + name = aws_cloudwatch_event_bus.main.name + arn = aws_cloudwatch_event_bus.main.arn + } +} + +## +# KMS +## + +output "kms_key_arn" { + description = "ARN of the KMS key used for encryption in the callbacks component" + value = module.kms.key_arn +} + +## +# Shared infrastructure for callback-clients component +## + +output "security_group_id" { + description = "Security group ID for per-client HTTPS delivery Lambda functions" + value = aws_security_group.https_client_lambda.id +} + +output "elasticache" { + description = "ElastiCache delivery state details for cross-component access" + value = { + endpoint = aws_elasticache_serverless_cache.delivery_state.endpoint[0].address + cache_name = aws_elasticache_serverless_cache.delivery_state.name + iam_username = aws_elasticache_user.delivery_state_iam.user_name + } +} + +output "client_config_bucket" { + description = "S3 bucket for client subscription configuration" + value = { + bucket = var.client_config_s3_bucket + arn = local.client_config_bucket_arn + } +} + +output "vpc_subnet_ids" { + description = "Private subnet IDs for Lambda VPC configuration" + value = try(local.acct.private_subnets[local.bc_name], []) +} + +output "lambda_s3_bucket" { + description = "S3 bucket ID for Lambda function artefacts" + value = local.acct.s3_buckets["lambda_function_artefacts"]["id"] +} + +output "log_destination_arn" { + description = "Firehose destination ARN for log forwarding" + value = local.log_destination_arn +} + +output "log_subscription_role_arn" { + description = "IAM role ARN for CloudWatch log subscription" + value = local.acct.log_subscription_role_arn } diff --git a/infrastructure/terraform/components/callbacks/pre.sh b/infrastructure/terraform/components/callbacks/pre.sh index 39eb0817..aa04f710 100755 --- a/infrastructure/terraform/components/callbacks/pre.sh +++ b/infrastructure/terraform/components/callbacks/pre.sh @@ -1,41 +1,12 @@ -# This script is run before the Terraform apply command. -# It ensures dependencies are installed, generates local client config files -# for terraform from S3-held subscriptions, and builds lambda workspaces. +# This script is run before Terraform apply for the callbacks component. +# It installs dependencies, generates any required build artefacts, +# and builds lambda workspaces. script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=_paths.sh -source "${script_dir}/_paths.sh" - -# Resolve deploy_mock_clients and deploy_perf_runner from tfvars; base_path/group/region/environment are in scope from terraform.sh -deploy_mock_clients="false" -deploy_perf_runner="false" -for _tfvar_file in \ - "${base_path}/etc/group_${group}.tfvars" \ - "${base_path}/etc/env_${region}_${environment}.tfvars"; do - if [[ -f "${_tfvar_file}" ]]; then - _val=$(grep -E '^\s*deploy_mock_clients\s*=' "${_tfvar_file}" | tail -1 | sed 's/.*=\s*//;s/\s*$//') - [ -n "${_val}" ] && deploy_mock_clients="${_val}" - _val=$(grep -E '^\s*deploy_perf_runner\s*=' "${_tfvar_file}" | tail -1 | sed 's/.*=\s*//;s/\s*$//') - [ -n "${_val}" ] && deploy_perf_runner="${_val}" - fi -done -echo "deploy_mock_clients resolved to: ${deploy_mock_clients}" -echo "deploy_perf_runner resolved to: ${deploy_perf_runner}" +repo_root="$(cd "${script_dir}/../../../.." && pwd)" pnpm install --frozen-lockfile pnpm run generate-dependencies -"${script_dir}/sync-client-config.sh" - -if [ "${deploy_mock_clients}" == "true" ]; then - cp "${bounded_context_root}/tests/integration/fixtures/subscriptions/"*.json "${clients_dir}/" - echo "Copied mock client subscription config fixtures into clients dir" -fi - -if [ "${deploy_perf_runner}" == "true" ]; then - cp "${bounded_context_root}/tests/performance/fixtures/subscriptions/"*.json "${clients_dir}/" - echo "Copied perf client subscription config fixtures into clients dir" -fi - pnpm run --recursive --if-present lambda-build diff --git a/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf b/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf deleted file mode 100644 index 9943affd..00000000 --- a/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf +++ /dev/null @@ -1,104 +0,0 @@ -resource "aws_s3_object" "mock_client_config" { - for_each = var.deploy_mock_clients ? toset(keys(local.config_clients)) : toset([]) - - bucket = module.client_config_bucket.id - key = "client_subscriptions/${local.config_clients[each.key].clientId}.json" - content = jsonencode(local.enriched_mock_config_clients[each.key]) - - kms_key_id = module.kms.key_arn - server_side_encryption = "aws:kms" - - content_type = "application/json" -} - -module "client_config_bucket" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-s3bucket.zip" - - name = "subscription-config" - - aws_account_id = var.aws_account_id - component = var.component - environment = var.environment - project = var.project - region = var.region - - default_tags = merge( - local.default_tags, - { - Description = "Client subscription configuration storage" - } - ) - - kms_key_arn = module.kms.key_arn - force_destroy = var.s3_enable_force_destroy - versioning = true - object_ownership = "BucketOwnerPreferred" - bucket_key_enabled = true - - policy_documents = [ - data.aws_iam_policy_document.client_config_bucket.json - ] -} - -data "aws_iam_policy_document" "client_config_bucket" { - statement { - sid = "AllowLambdaListAccess" - effect = "Allow" - - principals { - type = "AWS" - identifiers = [module.client_transform_filter_lambda.iam_role_arn] - } - - actions = [ - "s3:ListBucket", - ] - - resources = [ - local.client_config_bucket_arn, - ] - } - - statement { - sid = "AllowLambdaReadAccess" - effect = "Allow" - - principals { - type = "AWS" - identifiers = [module.client_transform_filter_lambda.iam_role_arn] - } - - actions = [ - "s3:GetObject", - ] - - resources = [ - "${local.client_config_bucket_arn}/*", - ] - } - - statement { - sid = "DenyInsecureTransport" - effect = "Deny" - - principals { - type = "*" - identifiers = ["*"] - } - - actions = [ - "s3:*", - ] - - resources = [ - local.client_config_bucket_arn, - "${local.client_config_bucket_arn}/*" - ] - - condition { - test = "Bool" - variable = "aws:SecureTransport" - values = ["false"] - } - } -} diff --git a/infrastructure/terraform/components/callbacks/ssm_parameter_applications_map.tf b/infrastructure/terraform/components/callbacks/ssm_parameter_applications_map.tf deleted file mode 100644 index 567647d1..00000000 --- a/infrastructure/terraform/components/callbacks/ssm_parameter_applications_map.tf +++ /dev/null @@ -1,19 +0,0 @@ -resource "random_password" "mock_application_id" { - for_each = var.deploy_mock_clients ? toset(keys(local.config_clients)) : toset([]) - length = 24 - special = false -} - -resource "aws_ssm_parameter" "applications_map" { - name = local.applications_map_parameter_name - type = "SecureString" - key_id = module.kms.key_arn - - value = var.deploy_mock_clients ? jsonencode({ - for id in keys(local.config_clients) : local.config_clients[id].clientId => random_password.mock_application_id[id].result - }) : jsonencode({}) - - lifecycle { - ignore_changes = [value] - } -} diff --git a/infrastructure/terraform/components/callbacks/sync-client-config.sh b/infrastructure/terraform/components/callbacks/sync-client-config.sh index 2c2a3ecb..653c137b 100755 --- a/infrastructure/terraform/components/callbacks/sync-client-config.sh +++ b/infrastructure/terraform/components/callbacks/sync-client-config.sh @@ -7,8 +7,8 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=_paths.sh -source "${script_dir}/_paths.sh" +repo_root="$(cd "${script_dir}/../../../.." && pwd)" +clients_dir="${repo_root}/infrastructure/terraform/modules/clients" : "${ENVIRONMENT:?ENVIRONMENT must be set}" : "${AWS_REGION:?AWS_REGION must be set}" @@ -18,9 +18,9 @@ cd "${repo_root}" rm -f "${clients_dir}"/*.json -bucket_name="nhs-${AWS_ACCOUNT_ID}-${AWS_REGION}-${ENVIRONMENT}-callbacks-subscription-config" +bucket_name="nhs-${AWS_ACCOUNT_ID}-${AWS_REGION}-main-acct-clie-client-configs" -s3_prefix="client_subscriptions/" +s3_prefix="${ENVIRONMENT}/client_subscriptions/" echo "Seeding client configs from s3://${bucket_name}/${s3_prefix} for ${ENVIRONMENT}/${AWS_REGION}" diff --git a/infrastructure/terraform/components/callbacks/variables.tf b/infrastructure/terraform/components/callbacks/variables.tf index 68e4eafd..89583ae6 100644 --- a/infrastructure/terraform/components/callbacks/variables.tf +++ b/infrastructure/terraform/components/callbacks/variables.tf @@ -35,11 +35,7 @@ variable "group" { # a default within its declaration in this file, because the variables # purpose is as an identifier unique to this component, rather # then to the environment from where all other variables come. -variable "component" { - type = string - description = "The variable encapsulating the name of this component" - default = "callbacks" -} + variable "default_tags" { type = map(string) @@ -151,13 +147,13 @@ variable "event_anomaly_band_width" { variable "deploy_mock_clients" { type = bool - description = "Flag to deploy mock webhook lambda for integration testing (test/dev environments only)" + description = "Flag indicating whether mock clients are deployed in callback-clients component (used by perf runner for log group references)" default = false } variable "deploy_perf_runner" { type = bool - description = "Flag to deploy the perf-runner lambda for performance testing (test/dev environments only)" + description = "Flag to deploy the perf-runner lambda for performance testing" default = false } @@ -167,49 +163,20 @@ variable "enable_xray_tracing" { default = false } -variable "message_root_uri" { +variable "client_config_s3_bucket" { type = string - description = "The root URI used for constructing message links in callback payloads" + description = "S3 bucket for client subscription configuration" } -variable "applications_map_parameter_name" { - type = string - default = null - description = "SSM Parameter Store path for the clientId-to-applicationData map, where applicationData is currently only the applicationId" -} - -variable "s3_enable_force_destroy" { - type = bool - description = "Whether to enable force destroy for the S3 buckets created in this module" - default = false -} - -variable "mtls_cert_s3_bucket" { +variable "message_root_uri" { type = string - description = "S3 bucket containing the mTLS client certificate bundle" - default = "" + description = "The root URI used for constructing message links in callback payloads" } -variable "mtls_cert_s3_key" { - type = string - description = "S3 key for the mTLS client certificate PEM bundle" - default = "" -} -variable "mtls_ca_s3_key" { - type = string - description = "S3 key for the CA certificate PEM bundle used for server verification" - default = "" -} variable "elasticache_data_storage_maximum_gb" { type = number description = "Maximum data storage in GB for the ElastiCache Serverless delivery state cache" default = 1 } - -variable "token_bucket_burst_capacity" { - type = number - description = "Token bucket burst capacity used by the rate limiter" - default = 2250 -} diff --git a/infrastructure/terraform/components/callbacks/versions.tf b/infrastructure/terraform/components/callbacks/versions.tf index d91998a2..55552749 100644 --- a/infrastructure/terraform/components/callbacks/versions.tf +++ b/infrastructure/terraform/components/callbacks/versions.tf @@ -4,18 +4,10 @@ terraform { source = "hashicorp/aws" version = "6.13" } - external = { - source = "hashicorp/external" - version = "~> 2.0" - } random = { source = "hashicorp/random" version = "~> 3.0" } - tls = { - source = "hashicorp/tls" - version = "~> 4.0" - } } required_version = ">= 1.10.1" diff --git a/infrastructure/terraform/modules/client-delivery/README.md b/infrastructure/terraform/modules/client-delivery/README.md index 22b98e26..7e76436e 100644 --- a/infrastructure/terraform/modules/client-delivery/README.md +++ b/infrastructure/terraform/modules/client-delivery/README.md @@ -9,11 +9,13 @@ No requirements. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [applications\_map\_parameter\_name](#input\_applications\_map\_parameter\_name) | SSM Parameter Store path for the clientId-to-applicationData map | `string` | n/a | yes | +| [applications\_map\_s3\_bucket](#input\_applications\_map\_s3\_bucket) | S3 bucket containing the applications map JSON | `string` | n/a | yes | +| [applications\_map\_s3\_key](#input\_applications\_map\_s3\_key) | S3 key for the applications map JSON file | `string` | n/a | yes | | [aws\_account\_id](#input\_aws\_account\_id) | Account ID | `string` | n/a | yes | | [client\_bus\_name](#input\_client\_bus\_name) | EventBridge bus name for subscription rules | `string` | n/a | yes | | [client\_config\_bucket](#input\_client\_config\_bucket) | S3 bucket name containing client subscription configuration | `string` | n/a | yes | | [client\_config\_bucket\_arn](#input\_client\_config\_bucket\_arn) | S3 bucket ARN containing client subscription configuration | `string` | n/a | yes | +| [client\_config\_key\_prefix](#input\_client\_config\_key\_prefix) | S3 key prefix for client subscription config objects | `string` | n/a | yes | | [client\_id](#input\_client\_id) | Unique identifier for this client | `string` | n/a | yes | | [component](#input\_component) | Component name | `string` | n/a | yes | | [elasticache\_cache\_name](#input\_elasticache\_cache\_name) | ElastiCache cache name for SigV4 token presigning | `string` | `""` | no | diff --git a/infrastructure/terraform/modules/client-delivery/iam_role_sqs_target.tf b/infrastructure/terraform/modules/client-delivery/iam_role_sqs_target.tf index 2f8e3c28..2e0e25db 100644 --- a/infrastructure/terraform/modules/client-delivery/iam_role_sqs_target.tf +++ b/infrastructure/terraform/modules/client-delivery/iam_role_sqs_target.tf @@ -43,15 +43,15 @@ data "aws_iam_policy_document" "https_client_lambda" { } statement { - sid = "SSMGetApplicationsMap" + sid = "S3GetApplicationsMap" effect = "Allow" actions = [ - "ssm:GetParameter", + "s3:GetObject", ] resources = [ - "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter${var.applications_map_parameter_name}", + "arn:aws:s3:::${var.applications_map_s3_bucket}/${var.applications_map_s3_key}", ] } @@ -64,7 +64,7 @@ data "aws_iam_policy_document" "https_client_lambda" { ] resources = [ - "${var.client_config_bucket_arn}/client_subscriptions/*", + "${var.client_config_bucket_arn}/${var.client_config_key_prefix}*", ] } diff --git a/infrastructure/terraform/modules/client-delivery/module_https_client_lambda.tf b/infrastructure/terraform/modules/client-delivery/module_https_client_lambda.tf index a1bb48f2..6dfabecb 100644 --- a/infrastructure/terraform/modules/client-delivery/module_https_client_lambda.tf +++ b/infrastructure/terraform/modules/client-delivery/module_https_client_lambda.tf @@ -36,11 +36,12 @@ module "https_client_lambda" { log_subscription_role_arn = var.log_subscription_role_arn lambda_env_vars = { - APPLICATIONS_MAP_PARAMETER = var.applications_map_parameter_name + APPLICATIONS_MAP_S3_BUCKET = var.applications_map_s3_bucket + APPLICATIONS_MAP_S3_KEY = var.applications_map_s3_key CLIENT_ID = var.client_id CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS = "60" CLIENT_SUBSCRIPTION_CONFIG_BUCKET = var.client_config_bucket - CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "client_subscriptions/" + CLIENT_SUBSCRIPTION_CONFIG_PREFIX = var.client_config_key_prefix DLQ_URL = module.dlq_delivery.sqs_queue_url ELASTICACHE_CACHE_NAME = var.elasticache_cache_name ELASTICACHE_ENDPOINT = var.elasticache_endpoint diff --git a/infrastructure/terraform/modules/client-delivery/variables.tf b/infrastructure/terraform/modules/client-delivery/variables.tf index 46f66f45..612bcd6f 100644 --- a/infrastructure/terraform/modules/client-delivery/variables.tf +++ b/infrastructure/terraform/modules/client-delivery/variables.tf @@ -70,9 +70,19 @@ variable "client_config_bucket_arn" { description = "S3 bucket ARN containing client subscription configuration" } -variable "applications_map_parameter_name" { +variable "client_config_key_prefix" { type = string - description = "SSM Parameter Store path for the clientId-to-applicationData map" + description = "S3 key prefix for client subscription config objects" +} + +variable "applications_map_s3_bucket" { + type = string + description = "S3 bucket containing the applications map JSON" +} + +variable "applications_map_s3_key" { + type = string + description = "S3 key for the applications map JSON file" } variable "lambda_s3_bucket" { diff --git a/lambdas/https-client-lambda/package.json b/lambdas/https-client-lambda/package.json index 88b36769..8082b859 100644 --- a/lambdas/https-client-lambda/package.json +++ b/lambdas/https-client-lambda/package.json @@ -3,7 +3,6 @@ "@aws-crypto/sha256-js": "catalog:aws", "@aws-sdk/client-s3": "catalog:aws", "@aws-sdk/client-sqs": "catalog:aws", - "@aws-sdk/client-ssm": "catalog:aws", "@aws-sdk/credential-providers": "catalog:aws", "@smithy/signature-v4": "catalog:aws", "@nhs-notify-client-callbacks/config-subscription-cache": "workspace:*", @@ -16,6 +15,7 @@ "p-map": "catalog:app" }, "devDependencies": { + "@smithy/types": "catalog:aws", "@tsconfig/node22": "catalog:tools", "@types/aws-lambda": "catalog:tools", "@types/jest": "catalog:test", diff --git a/lambdas/https-client-lambda/src/__tests__/applications-map.test.ts b/lambdas/https-client-lambda/src/__tests__/applications-map.test.ts new file mode 100644 index 00000000..3a7c8e92 --- /dev/null +++ b/lambdas/https-client-lambda/src/__tests__/applications-map.test.ts @@ -0,0 +1,119 @@ +import { GetObjectCommand } from "@aws-sdk/client-s3"; +import { Readable } from "node:stream"; +import type { SdkStream } from "@smithy/types"; + +import { getApplicationId, resetCache } from "services/applications-map"; + +const mockSend = jest.fn(); +jest.mock("@aws-sdk/client-s3", () => { + const actual = jest.requireActual("@aws-sdk/client-s3"); + return { + ...actual, + S3Client: jest.fn().mockImplementation(() => ({ + send: (...args: unknown[]) => mockSend(...args), + })), + }; +}); + +jest.mock("@nhs-notify-client-callbacks/logger", () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +function createS3Body(content: string): { Body: SdkStream } { + const stream = Readable.from([content]) as SdkStream; + stream.transformToString = jest.fn().mockResolvedValue(content); + return { Body: stream }; +} + +process.env.APPLICATIONS_MAP_S3_BUCKET = "test-bucket"; +process.env.APPLICATIONS_MAP_S3_KEY = "dev/applications-map.json"; + +describe("getApplicationId", () => { + beforeEach(() => { + mockSend.mockReset(); + resetCache(); + }); + + it("returns correct applicationId for a known clientId", async () => { + mockSend.mockResolvedValue( + createS3Body( + JSON.stringify({ + "client-1": "app-id-1", + "client-2": "app-id-2", + }), + ), + ); + + const result = await getApplicationId("client-1"); + + expect(result).toBe("app-id-1"); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand); + }); + + it("throws for unknown clientId", async () => { + mockSend.mockResolvedValue( + createS3Body(JSON.stringify({ "client-1": "app-id-1" })), + ); + + await expect(getApplicationId("unknown")).rejects.toThrow( + "No applicationId found for clientId 'unknown' in applications map", + ); + }); + + it("surfaces S3 SDK errors", async () => { + mockSend.mockRejectedValue(new Error("S3 unavailable")); + + await expect(getApplicationId("client-1")).rejects.toThrow( + "S3 unavailable", + ); + }); + + it("throws when env vars are not set", async () => { + const savedBucket = process.env.APPLICATIONS_MAP_S3_BUCKET; + const savedKey = process.env.APPLICATIONS_MAP_S3_KEY; + delete process.env.APPLICATIONS_MAP_S3_BUCKET; + delete process.env.APPLICATIONS_MAP_S3_KEY; + + resetCache(); + + await expect(getApplicationId("client-1")).rejects.toThrow( + "APPLICATIONS_MAP_S3_BUCKET and APPLICATIONS_MAP_S3_KEY are required", + ); + + process.env.APPLICATIONS_MAP_S3_BUCKET = savedBucket; + process.env.APPLICATIONS_MAP_S3_KEY = savedKey; + }); + + it("throws when S3 object body is empty", async () => { + const stream = Readable.from([]) as SdkStream; + stream.transformToString = jest.fn().mockResolvedValue(""); + mockSend.mockResolvedValue({ Body: stream }); + + await expect(getApplicationId("client-1")).rejects.toThrow("is empty"); + }); + + it("throws when S3 object contains invalid JSON", async () => { + mockSend.mockResolvedValue(createS3Body("not-json")); + + await expect(getApplicationId("client-1")).rejects.toThrow( + "contains invalid JSON", + ); + }); + + it("caches the applications map between calls", async () => { + mockSend.mockResolvedValue( + createS3Body(JSON.stringify({ "client-1": "app-id-1" })), + ); + + await getApplicationId("client-1"); + await getApplicationId("client-1"); + + expect(mockSend).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lambdas/https-client-lambda/src/__tests__/handler.test.ts b/lambdas/https-client-lambda/src/__tests__/handler.test.ts index 5e4dade3..1d46d6eb 100644 --- a/lambdas/https-client-lambda/src/__tests__/handler.test.ts +++ b/lambdas/https-client-lambda/src/__tests__/handler.test.ts @@ -20,7 +20,7 @@ jest.mock("services/config-loader", () => ({ })); const mockGetApplicationId = jest.fn(); -jest.mock("services/ssm-applications-map", () => ({ +jest.mock("services/applications-map", () => ({ getApplicationId: (...args: unknown[]) => mockGetApplicationId(...args), })); diff --git a/lambdas/https-client-lambda/src/__tests__/ssm-applications-map.test.ts b/lambdas/https-client-lambda/src/__tests__/ssm-applications-map.test.ts deleted file mode 100644 index 059023d1..00000000 --- a/lambdas/https-client-lambda/src/__tests__/ssm-applications-map.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { GetParameterCommand } from "@aws-sdk/client-ssm"; - -import { getApplicationId, resetCache } from "services/ssm-applications-map"; - -const mockSend = jest.fn(); -jest.mock("@aws-sdk/client-ssm", () => { - const actual = jest.requireActual("@aws-sdk/client-ssm"); - return { - ...actual, - SSMClient: jest.fn().mockImplementation(() => ({ - send: (...args: unknown[]) => mockSend(...args), - })), - }; -}); - -jest.mock("@nhs-notify-client-callbacks/logger", () => ({ - logger: { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - }, -})); - -process.env.APPLICATIONS_MAP_PARAMETER = "/test/applications-map"; - -describe("getApplicationId", () => { - beforeEach(() => { - mockSend.mockReset(); - resetCache(); - }); - - it("returns correct applicationId for a known clientId", async () => { - mockSend.mockResolvedValue({ - Parameter: { - Value: JSON.stringify({ - "client-1": "app-id-1", - "client-2": "app-id-2", - }), - }, - }); - - const result = await getApplicationId("client-1"); - - expect(result).toBe("app-id-1"); - expect(mockSend).toHaveBeenCalledTimes(1); - expect(mockSend.mock.calls[0][0]).toBeInstanceOf(GetParameterCommand); - }); - - it("throws for unknown clientId", async () => { - mockSend.mockResolvedValue({ - Parameter: { - Value: JSON.stringify({ "client-1": "app-id-1" }), - }, - }); - - await expect(getApplicationId("unknown")).rejects.toThrow( - "No applicationId found for clientId 'unknown' in SSM map", - ); - }); - - it("surfaces SSM SDK errors", async () => { - mockSend.mockRejectedValue(new Error("SSM unavailable")); - - await expect(getApplicationId("client-1")).rejects.toThrow( - "SSM unavailable", - ); - }); - - it("throws when APPLICATIONS_MAP_PARAMETER is not set", async () => { - let getFn: typeof getApplicationId; - const saved = process.env.APPLICATIONS_MAP_PARAMETER; - delete process.env.APPLICATIONS_MAP_PARAMETER; - - jest.isolateModules(() => { - // eslint-disable-next-line @typescript-eslint/no-require-imports -- jest.isolateModules requires synchronous require - getFn = require("services/ssm-applications-map").getApplicationId; - }); - - await expect(getFn!("client-1")).rejects.toThrow( - "APPLICATIONS_MAP_PARAMETER is required", - ); - - process.env.APPLICATIONS_MAP_PARAMETER = saved; - }); - - it("throws when SSM parameter value is empty", async () => { - mockSend.mockResolvedValue({ Parameter: { Value: undefined } }); - - await expect(getApplicationId("client-1")).rejects.toThrow( - "not found or has no value", - ); - }); - - it("throws when SSM parameter contains invalid JSON", async () => { - mockSend.mockResolvedValue({ - Parameter: { Value: "not-json" }, - }); - - await expect(getApplicationId("client-1")).rejects.toThrow( - "contains invalid JSON", - ); - }); - - it("caches the applications map between calls", async () => { - mockSend.mockResolvedValue({ - Parameter: { - Value: JSON.stringify({ "client-1": "app-id-1" }), - }, - }); - - await getApplicationId("client-1"); - await getApplicationId("client-1"); - - expect(mockSend).toHaveBeenCalledTimes(1); - }); -}); diff --git a/lambdas/https-client-lambda/src/handler.ts b/lambdas/https-client-lambda/src/handler.ts index 395815ad..f80d9928 100644 --- a/lambdas/https-client-lambda/src/handler.ts +++ b/lambdas/https-client-lambda/src/handler.ts @@ -3,7 +3,7 @@ import type { ClientCallbackPayload } from "@nhs-notify-client-callbacks/models" import pMap from "p-map"; import { logger } from "@nhs-notify-client-callbacks/logger"; import { loadTargetConfig } from "services/config-loader"; -import { getApplicationId } from "services/ssm-applications-map"; +import { getApplicationId } from "services/applications-map"; import { signPayload } from "services/payload-signer"; import { buildAgent } from "services/delivery/tls-agent-factory"; import { @@ -273,6 +273,7 @@ async function processTargetBatch( try { const outcome = await deliverRecord( record, + // eslint-disable-next-line security/detect-object-injection admittedMessages[index], target, applicationId, @@ -280,6 +281,7 @@ async function processTargetBatch( ); return { record, success: outcome.success, dlq: outcome.dlq }; } catch (error) { + // eslint-disable-next-line security/detect-object-injection const correlationId = extractCorrelationId(admittedMessages[index]); logger.error("Failed to process record", { messageId: record.messageId, diff --git a/lambdas/https-client-lambda/src/services/applications-map.ts b/lambdas/https-client-lambda/src/services/applications-map.ts new file mode 100644 index 00000000..9a86d86c --- /dev/null +++ b/lambdas/https-client-lambda/src/services/applications-map.ts @@ -0,0 +1,73 @@ +import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { logger } from "@nhs-notify-client-callbacks/logger"; + +const s3Client = new S3Client({}); + +const DEFAULT_CACHE_TTL_MS = 300_000; // 5 minutes + +let cachedMap: Map | undefined; +let cacheExpiresAt = 0; + +async function loadMap(): Promise> { + if (cachedMap && Date.now() < cacheExpiresAt) { + return cachedMap; + } + + const { APPLICATIONS_MAP_S3_BUCKET, APPLICATIONS_MAP_S3_KEY } = process.env; + if (!APPLICATIONS_MAP_S3_BUCKET || !APPLICATIONS_MAP_S3_KEY) { + throw new Error( + "APPLICATIONS_MAP_S3_BUCKET and APPLICATIONS_MAP_S3_KEY are required", + ); + } + + const response = await s3Client.send( + new GetObjectCommand({ + Bucket: APPLICATIONS_MAP_S3_BUCKET, + Key: APPLICATIONS_MAP_S3_KEY, + }), + ); + + const body = await response.Body?.transformToString(); + if (!body) { + throw new Error( + `S3 object 's3://${APPLICATIONS_MAP_S3_BUCKET}/${APPLICATIONS_MAP_S3_KEY}' is empty`, + ); + } + + let parsed: Record; + try { + parsed = JSON.parse(body) as Record; + } catch { + throw new Error( + `S3 object 's3://${APPLICATIONS_MAP_S3_BUCKET}/${APPLICATIONS_MAP_S3_KEY}' contains invalid JSON`, + ); + } + + cachedMap = new Map(Object.entries(parsed)); + const ttlMs = + Number(process.env.APPLICATIONS_MAP_CACHE_TTL_MS) || DEFAULT_CACHE_TTL_MS; + cacheExpiresAt = Date.now() + ttlMs; + logger.info("Applications map loaded from S3", { + bucket: APPLICATIONS_MAP_S3_BUCKET, + key: APPLICATIONS_MAP_S3_KEY, + }); + return cachedMap; +} + +export async function getApplicationId(clientId: string): Promise { + const map = await loadMap(); + const applicationId = map.get(clientId); + + if (!applicationId) { + throw new Error( + `No applicationId found for clientId '${clientId}' in applications map`, + ); + } + + return applicationId; +} + +export function resetCache(): void { + cachedMap = undefined; + cacheExpiresAt = 0; +} diff --git a/lambdas/https-client-lambda/src/services/ssm-applications-map.ts b/lambdas/https-client-lambda/src/services/ssm-applications-map.ts deleted file mode 100644 index 999c23d9..00000000 --- a/lambdas/https-client-lambda/src/services/ssm-applications-map.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm"; -import { logger } from "@nhs-notify-client-callbacks/logger"; - -const ssmClient = new SSMClient({}); - -const DEFAULT_CACHE_TTL_MS = 300_000; // 5 minutes - -let cachedMap: Map | undefined; -let cacheExpiresAt = 0; - -async function loadMap(): Promise> { - if (cachedMap && Date.now() < cacheExpiresAt) { - return cachedMap; - } - - const { APPLICATIONS_MAP_PARAMETER } = process.env; - if (!APPLICATIONS_MAP_PARAMETER) { - throw new Error("APPLICATIONS_MAP_PARAMETER is required"); - } - - const response = await ssmClient.send( - new GetParameterCommand({ - Name: APPLICATIONS_MAP_PARAMETER, - WithDecryption: true, - }), - ); - - if (!response.Parameter?.Value) { - throw new Error( - `SSM parameter '${APPLICATIONS_MAP_PARAMETER}' not found or has no value`, - ); - } - - let parsed: Record; - try { - parsed = JSON.parse(response.Parameter.Value) as Record; - } catch { - throw new Error( - `SSM parameter '${APPLICATIONS_MAP_PARAMETER}' contains invalid JSON`, - ); - } - - cachedMap = new Map(Object.entries(parsed)); - const ttlMs = - Number(process.env.APPLICATIONS_MAP_CACHE_TTL_MS) || DEFAULT_CACHE_TTL_MS; - cacheExpiresAt = Date.now() + ttlMs; - logger.info("Applications map loaded from SSM", { - parameterName: APPLICATIONS_MAP_PARAMETER, - }); - return cachedMap; -} - -export async function getApplicationId(clientId: string): Promise { - const map = await loadMap(); - const applicationId = map.get(clientId); - - if (!applicationId) { - throw new Error( - `No applicationId found for clientId '${clientId}' in SSM map`, - ); - } - - return applicationId; -} - -export function resetCache(): void { - cachedMap = undefined; - cacheExpiresAt = 0; -} diff --git a/lambdas/perf-runner-lambda/src/__tests__/cloudwatch.test.ts b/lambdas/perf-runner-lambda/src/__tests__/cloudwatch.test.ts index 526de638..a6f4d36c 100644 --- a/lambdas/perf-runner-lambda/src/__tests__/cloudwatch.test.ts +++ b/lambdas/perf-runner-lambda/src/__tests__/cloudwatch.test.ts @@ -24,7 +24,7 @@ describe("queryMetricsSnapshot", () => { const result = await queryMetricsSnapshot( mockCloudWatchClient, - "/aws/lambda/nhs-dev-callbacks-client-transform-filter", + "/aws/lambda/nhs-dev-cb-client-transform-filter", 1_700_000_000, 1_700_000_060, ); diff --git a/lambdas/perf-runner-lambda/src/__tests__/index.test.ts b/lambdas/perf-runner-lambda/src/__tests__/index.test.ts index 3c33bfd6..1444c919 100644 --- a/lambdas/perf-runner-lambda/src/__tests__/index.test.ts +++ b/lambdas/perf-runner-lambda/src/__tests__/index.test.ts @@ -40,11 +40,12 @@ beforeEach(() => { mockRunPerformanceTest.mockResolvedValue(mockResult); process.env.INBOUND_QUEUE_URL = "https://sqs.example.invalid/queue"; process.env.TRANSFORM_FILTER_LOG_GROUP = - "/aws/lambda/nhs-dev-callbacks-client-transform-filter"; + "/aws/lambda/nhs-dev-cb-client-transform-filter"; process.env.DELIVERY_LOG_GROUP_PREFIX = - "/aws/lambda/nhs-dev-callbacks-https-client-"; - process.env.MOCK_WEBHOOK_LOG_GROUP = - "/aws/lambda/nhs-dev-callbacks-mock-webhook"; + "/aws/lambda/nhs-dev-cbc-https-client-"; + process.env.DELIVERY_QUEUE_URL_PREFIX = + "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-cbc-"; + process.env.MOCK_WEBHOOK_LOG_GROUP = "/aws/lambda/nhs-dev-cbc-mock-webhook"; process.env.ELASTICACHE_ENDPOINT = "cache.example.invalid"; process.env.ELASTICACHE_CACHE_NAME = "test-cache"; process.env.ELASTICACHE_IAM_USERNAME = "test-user"; @@ -59,9 +60,11 @@ describe("handler", () => { expect(mockRunPerformanceTest).toHaveBeenCalledWith( expect.objectContaining({ queueUrl: "https://sqs.example.invalid/queue", - logGroupName: "/aws/lambda/nhs-dev-callbacks-client-transform-filter", - deliveryLogGroupPrefix: "/aws/lambda/nhs-dev-callbacks-https-client-", - mockWebhookLogGroup: "/aws/lambda/nhs-dev-callbacks-mock-webhook", + logGroupName: "/aws/lambda/nhs-dev-cb-client-transform-filter", + deliveryLogGroupPrefix: "/aws/lambda/nhs-dev-cbc-https-client-", + deliveryQueueUrlPrefix: + "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-cbc-", + mockWebhookLogGroup: "/aws/lambda/nhs-dev-cbc-mock-webhook", }), DEFAULT_SCENARIO, "test-id", @@ -159,7 +162,7 @@ describe("handler", () => { expect(mockRunPerformanceTest).toHaveBeenCalledWith( expect.objectContaining({ - mockWebhookLogGroup: "/aws/lambda/nhs-dev-callbacks-mock-webhook", + mockWebhookLogGroup: "/aws/lambda/nhs-dev-cbc-mock-webhook", }), expect.anything(), "webhook-test", diff --git a/lambdas/perf-runner-lambda/src/__tests__/purge.test.ts b/lambdas/perf-runner-lambda/src/__tests__/purge.test.ts index 14bcf247..5cc485ed 100644 --- a/lambdas/perf-runner-lambda/src/__tests__/purge.test.ts +++ b/lambdas/perf-runner-lambda/src/__tests__/purge.test.ts @@ -22,19 +22,38 @@ const scenario: Scenario = { }; const inboundQueueUrl = - "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-callbacks-inbound-event-queue"; + "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-cb-inbound-event-queue"; +const deliveryQueueUrlPrefix = + "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-cbc-"; describe("deriveQueueUrls", () => { - it("derives all queue URLs from the inbound queue URL and scenario", () => { + it("derives all queue URLs using separate delivery prefix", () => { + const urls = deriveQueueUrls( + inboundQueueUrl, + scenario, + deliveryQueueUrlPrefix, + ); + + expect(urls).toEqual([ + "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-cb-inbound-event-queue", + "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-cb-inbound-event-dlq-queue", + "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-cbc-perf-client-1-delivery-queue", + "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-cbc-perf-client-1-delivery-dlq-queue", + "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-cbc-perf-client-2-delivery-queue", + "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-cbc-perf-client-2-delivery-dlq-queue", + ]); + }); + + it("falls back to inbound base URL when no delivery prefix provided", () => { const urls = deriveQueueUrls(inboundQueueUrl, scenario); expect(urls).toEqual([ - "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-callbacks-inbound-event-queue", - "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-callbacks-inbound-event-dlq-queue", - "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-callbacks-perf-client-1-delivery-queue", - "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-callbacks-perf-client-1-delivery-dlq-queue", - "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-callbacks-perf-client-2-delivery-queue", - "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-callbacks-perf-client-2-delivery-dlq-queue", + "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-cb-inbound-event-queue", + "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-cb-inbound-event-dlq-queue", + "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-cb-perf-client-1-delivery-queue", + "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-cb-perf-client-1-delivery-dlq-queue", + "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-cb-perf-client-2-delivery-queue", + "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-cb-perf-client-2-delivery-dlq-queue", ]); }); @@ -57,13 +76,17 @@ describe("deriveQueueUrls", () => { ], }; - const urls = deriveQueueUrls(inboundQueueUrl, duplicateScenario); + const urls = deriveQueueUrls( + inboundQueueUrl, + duplicateScenario, + deliveryQueueUrlPrefix, + ); expect(urls).toEqual([ - "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-callbacks-inbound-event-queue", - "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-callbacks-inbound-event-dlq-queue", - "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-callbacks-perf-client-1-delivery-queue", - "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-callbacks-perf-client-1-delivery-dlq-queue", + "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-cb-inbound-event-queue", + "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-cb-inbound-event-dlq-queue", + "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-cbc-perf-client-1-delivery-queue", + "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-cbc-perf-client-1-delivery-dlq-queue", ]); }); }); diff --git a/lambdas/perf-runner-lambda/src/__tests__/runner.test.ts b/lambdas/perf-runner-lambda/src/__tests__/runner.test.ts index 622e98a4..a766114e 100644 --- a/lambdas/perf-runner-lambda/src/__tests__/runner.test.ts +++ b/lambdas/perf-runner-lambda/src/__tests__/runner.test.ts @@ -98,8 +98,10 @@ const deps: RunnerDeps = { sqsClient: {} as SQSClient, cloudWatchClient: {} as CloudWatchLogsClient, queueUrl: "https://sqs.example.invalid/queue", - logGroupName: "/aws/lambda/nhs-dev-callbacks-client-transform-filter", - deliveryLogGroupPrefix: "/aws/lambda/nhs-dev-callbacks-https-client-", + logGroupName: "/aws/lambda/nhs-dev-cb-client-transform-filter", + deliveryLogGroupPrefix: "/aws/lambda/nhs-dev-cbc-https-client-", + deliveryQueueUrlPrefix: + "https://sqs.eu-west-2.amazonaws.com/123456789/nhs-dev-cbc-", }; beforeEach(() => { @@ -355,8 +357,8 @@ describe("runPerformanceTest", () => { expect(mockQueryDeliveryMetricsSnapshot).toHaveBeenCalledWith( deps.cloudWatchClient, expect.arrayContaining([ - "/aws/lambda/nhs-dev-callbacks-https-client-perf-client-1", - "/aws/lambda/nhs-dev-callbacks-https-client-perf-client-2", + "/aws/lambda/nhs-dev-cbc-https-client-perf-client-1", + "/aws/lambda/nhs-dev-cbc-https-client-perf-client-2", ]), expect.any(Number), expect.any(Number), @@ -482,13 +484,13 @@ describe("runPerformanceTest", () => { expect(mockQueryPerClientRateTimeline).toHaveBeenCalledTimes(2); expect(mockQueryPerClientRateTimeline).toHaveBeenCalledWith( deps.cloudWatchClient, - "/aws/lambda/nhs-dev-callbacks-https-client-perf-client-1", + "/aws/lambda/nhs-dev-cbc-https-client-perf-client-1", expect.any(Number), expect.any(Number), ); expect(mockQueryPerClientRateTimeline).toHaveBeenCalledWith( deps.cloudWatchClient, - "/aws/lambda/nhs-dev-callbacks-https-client-perf-client-2", + "/aws/lambda/nhs-dev-cbc-https-client-perf-client-2", expect.any(Number), expect.any(Number), ); @@ -553,7 +555,11 @@ describe("runPerformanceTest", () => { await runPerformanceTest(deps, scenario, "test-purge", immediateSleep); - expect(mockDeriveQueueUrls).toHaveBeenCalledWith(deps.queueUrl, scenario); + expect(mockDeriveQueueUrls).toHaveBeenCalledWith( + deps.queueUrl, + scenario, + deps.deliveryQueueUrlPrefix, + ); expect(mockPurgeQueues).toHaveBeenCalledTimes(2); }); diff --git a/lambdas/perf-runner-lambda/src/index.ts b/lambdas/perf-runner-lambda/src/index.ts index 5974627b..f201f1ea 100644 --- a/lambdas/perf-runner-lambda/src/index.ts +++ b/lambdas/perf-runner-lambda/src/index.ts @@ -20,6 +20,7 @@ export async function handler( const queueUrl = process.env.INBOUND_QUEUE_URL; const logGroupName = process.env.TRANSFORM_FILTER_LOG_GROUP; const deliveryLogGroupPrefix = process.env.DELIVERY_LOG_GROUP_PREFIX; + const deliveryQueueUrlPrefix = process.env.DELIVERY_QUEUE_URL_PREFIX; const mockWebhookLogGroup = process.env.MOCK_WEBHOOK_LOG_GROUP; const elasticacheEndpoint = process.env.ELASTICACHE_ENDPOINT; const elasticacheCacheName = process.env.ELASTICACHE_CACHE_NAME; @@ -58,6 +59,7 @@ export async function handler( queueUrl, logGroupName, deliveryLogGroupPrefix, + deliveryQueueUrlPrefix, mockWebhookLogGroup, }, scenario, diff --git a/lambdas/perf-runner-lambda/src/purge.ts b/lambdas/perf-runner-lambda/src/purge.ts index e363e706..fcf7810b 100644 --- a/lambdas/perf-runner-lambda/src/purge.ts +++ b/lambdas/perf-runner-lambda/src/purge.ts @@ -4,17 +4,19 @@ import type { Scenario } from "types"; export function deriveQueueUrls( inboundQueueUrl: string, scenario: Scenario, + deliveryQueueUrlPrefix?: string, ): string[] { // eslint-disable-next-line sonarjs/null-dereference -- String.replace always returns a string - const baseUrl = inboundQueueUrl.replace(/inbound-event-queue$/, ""); + const inboundBase = inboundQueueUrl.replace(/inbound-event-queue$/, ""); + const deliveryBase = deliveryQueueUrlPrefix ?? inboundBase; const clientIds = [...new Set(scenario.eventMix.map((e) => e.clientId))]; return [ inboundQueueUrl, - `${baseUrl}inbound-event-dlq-queue`, + `${inboundBase}inbound-event-dlq-queue`, ...clientIds.flatMap((id) => [ - `${baseUrl}${id}-delivery-queue`, - `${baseUrl}${id}-delivery-dlq-queue`, + `${deliveryBase}${id}-delivery-queue`, + `${deliveryBase}${id}-delivery-dlq-queue`, ]), ]; } diff --git a/lambdas/perf-runner-lambda/src/runner.ts b/lambdas/perf-runner-lambda/src/runner.ts index 7a5b5ee6..42821054 100644 --- a/lambdas/perf-runner-lambda/src/runner.ts +++ b/lambdas/perf-runner-lambda/src/runner.ts @@ -108,7 +108,11 @@ export async function runPerformanceTest( const testStartMs = Date.now(); - const queueUrls = deriveQueueUrls(deps.queueUrl, scenario); + const queueUrls = deriveQueueUrls( + deps.queueUrl, + scenario, + deps.deliveryQueueUrlPrefix, + ); await purgeQueues(deps.sqsClient, queueUrls); if (elastiCacheDeps) { await flushElastiCache(elastiCacheDeps); diff --git a/lambdas/perf-runner-lambda/src/types.ts b/lambdas/perf-runner-lambda/src/types.ts index 4415ef63..3900dd5a 100644 --- a/lambdas/perf-runner-lambda/src/types.ts +++ b/lambdas/perf-runner-lambda/src/types.ts @@ -123,6 +123,7 @@ export type RunnerDeps = { queueUrl: string; logGroupName: string; deliveryLogGroupPrefix?: string; + deliveryQueueUrlPrefix?: string; mockWebhookLogGroup?: string; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12926732..19aba6cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,9 +55,6 @@ catalogs: '@aws-sdk/client-sqs': specifier: ^3.1023.0 version: 3.1026.0 - '@aws-sdk/client-ssm': - specifier: ^3.1025.0 - version: 3.1029.0 '@aws-sdk/client-sts': specifier: ^3.1023.0 version: 3.1026.0 @@ -67,6 +64,9 @@ catalogs: '@smithy/signature-v4': specifier: ^5.0.0 version: 5.3.13 + '@smithy/types': + specifier: ^4.3.1 + version: 4.14.0 lint: '@eslint/js': specifier: ^9.39.4 @@ -340,9 +340,6 @@ importers: '@aws-sdk/client-sqs': specifier: catalog:aws version: 3.1026.0 - '@aws-sdk/client-ssm': - specifier: catalog:aws - version: 3.1029.0 '@aws-sdk/credential-providers': specifier: catalog:aws version: 3.1026.0 @@ -374,6 +371,9 @@ importers: specifier: catalog:app version: 4.0.0 devDependencies: + '@smithy/types': + specifier: catalog:aws + version: 4.14.0 '@tsconfig/node22': specifier: catalog:tools version: 22.0.5 @@ -688,9 +688,6 @@ importers: '@aws-sdk/client-s3': specifier: catalog:aws version: 3.1029.0 - '@aws-sdk/client-ssm': - specifier: catalog:aws - version: 3.1029.0 '@aws-sdk/client-sts': specifier: catalog:aws version: 3.1026.0 @@ -713,6 +710,9 @@ importers: specifier: catalog:app version: 4.3.6 devDependencies: + '@smithy/types': + specifier: catalog:aws + version: 4.14.0 '@types/jest': specifier: catalog:test version: 30.0.0 @@ -783,10 +783,6 @@ packages: resolution: {integrity: sha512-b7z2WI1tqObk4U7vUbmBfXIeFhxKbFr7xQ4rWi879iFl5aSPvpd1WAmLi6z1boVKTEwEqHALuE5MyGBHhOCy5A==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-ssm@3.1029.0': - resolution: {integrity: sha512-LthC1Dkh7r4ihZ7EI+6Sms9Ml0XQXoBZbw5LmtT1EJElriMugAfMnG5pKzDAcWpLiZgVBSZVai7moQR/QM/cCw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/client-sts@3.1026.0': resolution: {integrity: sha512-kyqU8QMroxh6vc22cLWRT/wk5I142PiwGpGosnqJ36mLmiLtn84HuDYyivaNRAjKWIUQNlWeB0HHSoeqbn2O6Q==} engines: {node: '>=20.0.0'} @@ -4952,51 +4948,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-ssm@3.1029.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.27 - '@aws-sdk/credential-provider-node': 3.972.30 - '@aws-sdk/middleware-host-header': 3.972.9 - '@aws-sdk/middleware-logger': 3.972.9 - '@aws-sdk/middleware-recursion-detection': 3.972.10 - '@aws-sdk/middleware-user-agent': 3.972.29 - '@aws-sdk/region-config-resolver': 3.972.11 - '@aws-sdk/types': 3.973.7 - '@aws-sdk/util-endpoints': 3.996.6 - '@aws-sdk/util-user-agent-browser': 3.972.9 - '@aws-sdk/util-user-agent-node': 3.973.15 - '@smithy/config-resolver': 4.4.14 - '@smithy/core': 3.23.14 - '@smithy/fetch-http-handler': 5.3.16 - '@smithy/hash-node': 4.2.13 - '@smithy/invalid-dependency': 4.2.13 - '@smithy/middleware-content-length': 4.2.13 - '@smithy/middleware-endpoint': 4.4.29 - '@smithy/middleware-retry': 4.5.0 - '@smithy/middleware-serde': 4.2.17 - '@smithy/middleware-stack': 4.2.13 - '@smithy/node-config-provider': 4.3.13 - '@smithy/node-http-handler': 4.5.2 - '@smithy/protocol-http': 5.3.13 - '@smithy/smithy-client': 4.12.9 - '@smithy/types': 4.14.0 - '@smithy/url-parser': 4.2.13 - '@smithy/util-base64': 4.3.2 - '@smithy/util-body-length-browser': 4.2.2 - '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.3.45 - '@smithy/util-defaults-mode-node': 4.2.49 - '@smithy/util-endpoints': 3.3.4 - '@smithy/util-middleware': 4.2.13 - '@smithy/util-retry': 4.3.0 - '@smithy/util-utf8': 4.2.2 - '@smithy/util-waiter': 4.2.15 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/client-sts@3.1026.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3e9e890d..f138cdcc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -31,11 +31,11 @@ catalogs: "@aws-sdk/client-cloudwatch-logs": "^3.1023.0" "@aws-sdk/client-s3": "^3.1024.0" "@aws-sdk/client-sqs": "^3.1023.0" - "@aws-sdk/client-ssm": "^3.1025.0" "@aws-crypto/sha256-js": "^5.2.0" "@aws-sdk/client-sts": "^3.1023.0" "@aws-sdk/credential-providers": "^3.1023.0" "@smithy/signature-v4": "^5.0.0" + "@smithy/types": "^4.3.1" lint: "@eslint/js": "^9.39.4" "@stylistic/eslint-plugin": "^5.10.0" diff --git a/tests/integration/dlq-alarms.test.ts b/tests/integration/dlq-alarms.test.ts index c4f69fa8..ae1e1bff 100644 --- a/tests/integration/dlq-alarms.test.ts +++ b/tests/integration/dlq-alarms.test.ts @@ -13,10 +13,10 @@ import { import { buildMockClientDlqQueueUrl } from "./helpers/sqs"; function buildDlqDepthAlarmName( - { component, environment, project }: DeploymentDetails, + { clientComponent, environment, project }: DeploymentDetails, clientId: string, ): string { - return `${project}-${environment}-${component}-${clientId}-dlq-depth`; + return `${project}-${environment}-${clientComponent}-${clientId}-dlq-depth`; } function getQueueNameFromUrl(queueUrl: string): string { diff --git a/tests/integration/helpers/sqs.ts b/tests/integration/helpers/sqs.ts index 5cdcc3a9..b0d3f4ff 100644 --- a/tests/integration/helpers/sqs.ts +++ b/tests/integration/helpers/sqs.ts @@ -34,7 +34,8 @@ function buildReceiveMessageInput( } function buildQueueUrl( - { accountId, component, environment, project, region }: DeploymentDetails, + { accountId, environment, project, region }: DeploymentDetails, + component: string, name: string, options?: { appendQueueSuffix?: boolean }, ): string { @@ -49,14 +50,22 @@ export function buildMockClientDlqQueueUrl( deploymentDetails: DeploymentDetails, clientId: string, ): string { - return buildQueueUrl(deploymentDetails, `${clientId}-delivery-dlq`); + return buildQueueUrl( + deploymentDetails, + deploymentDetails.clientComponent, + `${clientId}-delivery-dlq`, + ); } export function buildMockClientDeliveryQueueUrl( deploymentDetails: DeploymentDetails, clientId: string, ): string { - return buildQueueUrl(deploymentDetails, `${clientId}-delivery`); + return buildQueueUrl( + deploymentDetails, + deploymentDetails.clientComponent, + `${clientId}-delivery`, + ); } export async function sendSqsEvent( diff --git a/tests/integration/helpers/test-context.ts b/tests/integration/helpers/test-context.ts index df5a31f5..f0d6a6e6 100644 --- a/tests/integration/helpers/test-context.ts +++ b/tests/integration/helpers/test-context.ts @@ -36,7 +36,10 @@ export function createTestContext(): TestContext { deployment, inboundQueueUrl: buildInboundEventQueueUrl(deployment), inboundDlqUrl: buildInboundEventDlqQueueUrl(deployment), - webhookLogGroup: buildLambdaLogGroupName(deployment, "mock-webhook"), + webhookLogGroup: buildLambdaLogGroupName( + { ...deployment, component: deployment.clientComponent }, + "mock-webhook", + ), startTime: Date.now(), clientDlqUrl: (clientId) => buildMockClientDlqQueueUrl(deployment, clientId), diff --git a/tests/test-support/helpers/deployment.ts b/tests/test-support/helpers/deployment.ts index 20bf1f59..44e720ff 100644 --- a/tests/test-support/helpers/deployment.ts +++ b/tests/test-support/helpers/deployment.ts @@ -3,6 +3,7 @@ export type DeploymentDetails = { environment: string; project: string; component: string; + clientComponent: string; accountId: string; }; @@ -10,7 +11,8 @@ export function getDeploymentDetails(): DeploymentDetails { const region = process.env.AWS_REGION ?? "eu-west-2"; const environment = process.env.ENVIRONMENT; const project = process.env.PROJECT ?? "nhs"; - const component = process.env.COMPONENT ?? "callbacks"; + const component = process.env.COMPONENT ?? "cb"; + const clientComponent = process.env.CLIENT_COMPONENT ?? "cbc"; const accountId = process.env.AWS_ACCOUNT_ID; if (!environment) { @@ -21,7 +23,14 @@ export function getDeploymentDetails(): DeploymentDetails { throw new Error("AWS_ACCOUNT_ID environment variable must be set"); } - return { region, environment, project, component, accountId }; + return { + region, + environment, + project, + component, + clientComponent, + accountId, + }; } export function buildSubscriptionConfigBucketName({ diff --git a/tests/test-support/helpers/sqs.ts b/tests/test-support/helpers/sqs.ts index 7db58a1a..ccad60a2 100644 --- a/tests/test-support/helpers/sqs.ts +++ b/tests/test-support/helpers/sqs.ts @@ -1,7 +1,8 @@ import type { DeploymentDetails } from "./deployment"; function buildQueueUrl( - { accountId, component, environment, project, region }: DeploymentDetails, + { accountId, environment, project, region }: DeploymentDetails, + component: string, name: string, options?: { appendQueueSuffix?: boolean }, ): string { @@ -15,13 +16,22 @@ function buildQueueUrl( export function buildInboundEventQueueUrl( deploymentDetails: DeploymentDetails, ): string { - return buildQueueUrl(deploymentDetails, "inbound-event"); + return buildQueueUrl( + deploymentDetails, + deploymentDetails.component, + "inbound-event", + ); } export function buildInboundEventDlqQueueUrl( deploymentDetails: DeploymentDetails, ): string { - return buildQueueUrl(deploymentDetails, "inbound-event-dlq", { - appendQueueSuffix: false, - }); + return buildQueueUrl( + deploymentDetails, + deploymentDetails.component, + "inbound-event-dlq", + { + appendQueueSuffix: false, + }, + ); } diff --git a/tools/client-subscriptions-management/package.json b/tools/client-subscriptions-management/package.json index 4d934470..c2e3ebc4 100644 --- a/tools/client-subscriptions-management/package.json +++ b/tools/client-subscriptions-management/package.json @@ -25,7 +25,6 @@ }, "dependencies": { "@aws-sdk/client-s3": "catalog:aws", - "@aws-sdk/client-ssm": "catalog:aws", "@aws-sdk/client-sts": "catalog:aws", "@aws-sdk/credential-providers": "catalog:aws", "@nhs-notify-client-callbacks/models": "workspace:*", @@ -35,6 +34,7 @@ "zod": "catalog:app" }, "devDependencies": { + "@smithy/types": "catalog:aws", "@types/jest": "catalog:test", "@types/node": "catalog:tools", "@types/yargs": "catalog:tools", diff --git a/tools/client-subscriptions-management/src/__tests__/aws.test.ts b/tools/client-subscriptions-management/src/__tests__/aws.test.ts index f08d0bda..31bbc095 100644 --- a/tools/client-subscriptions-management/src/__tests__/aws.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/aws.test.ts @@ -1,8 +1,9 @@ import { + deriveApplicationsMapBucketName, + deriveApplicationsMapKey, deriveBucketName, - deriveParameterName, + resolveApplicationsMapLocation, resolveBucketName, - resolveParameterName, resolveProfile, resolveRegion, } from "src/aws"; @@ -24,20 +25,18 @@ describe("aws", () => { it("derives bucket name from environment using STS account ID", async () => { await expect( resolveBucketName({ environment: "dev", region: "eu-west-2" }), - ).resolves.toBe( - "nhs-123456789012-eu-west-2-dev-callbacks-subscription-config", - ); + ).resolves.toBe("nhs-123456789012-eu-west-2-dev-cb-subscription-config"); }); it("uses default region eu-west-2 when region is not provided", async () => { await expect(resolveBucketName({ environment: "dev" })).resolves.toBe( - "nhs-123456789012-eu-west-2-dev-callbacks-subscription-config", + "nhs-123456789012-eu-west-2-dev-cb-subscription-config", ); }); it("derives bucket name correctly", () => { expect(deriveBucketName("123456789012", "dev", "eu-west-2")).toBe( - "nhs-123456789012-eu-west-2-dev-callbacks-subscription-config", + "nhs-123456789012-eu-west-2-dev-cb-subscription-config", ); }); @@ -81,37 +80,40 @@ describe("aws", () => { expect(resolveRegion(undefined, {} as NodeJS.ProcessEnv)).toBeUndefined(); }); - it("derives parameter name from environment", () => { - expect(deriveParameterName("dev")).toBe( - "/nhs/dev/callbacks/applications-map", + it("derives applications map bucket name", () => { + expect(deriveApplicationsMapBucketName("123456789012", "eu-west-2")).toBe( + "nhs-123456789012-eu-west-2-main-acct-clie-apps-map", ); }); - it("resolves parameter name from explicit argument", () => { - expect(resolveParameterName({ parameterName: "/custom/path" })).toBe( - "/custom/path", - ); + it("derives applications map key from environment", () => { + expect(deriveApplicationsMapKey("dev")).toBe("dev/applications-map.json"); }); - it("derives parameter name from environment argument", () => { - expect(resolveParameterName({ environment: "dev" })).toBe( - "/nhs/dev/callbacks/applications-map", - ); + it("resolves applications map location from explicit args", async () => { + await expect( + resolveApplicationsMapLocation({ + bucket: "my-bucket", + key: "my-key.json", + }), + ).resolves.toEqual({ bucket: "my-bucket", key: "my-key.json" }); }); - it("derives parameter name from ENVIRONMENT env var", () => { - expect( - resolveParameterName({ - env: { ENVIRONMENT: "staging" } as NodeJS.ProcessEnv, + it("derives applications map location from environment", async () => { + await expect( + resolveApplicationsMapLocation({ + environment: "dev", + region: "eu-west-2", }), - ).toBe("/nhs/staging/callbacks/applications-map"); + ).resolves.toEqual({ + bucket: "nhs-123456789012-eu-west-2-main-acct-clie-apps-map", + key: "dev/applications-map.json", + }); }); - it("throws when no parameter name can be resolved", () => { - expect(() => - resolveParameterName({ env: {} as NodeJS.ProcessEnv }), - ).toThrow( - "Environment is required to derive parameter name. Please provide via --environment or ENVIRONMENT env var.", - ); + it("throws when no environment for applications map location", async () => { + await expect( + resolveApplicationsMapLocation({ env: {} as NodeJS.ProcessEnv } as any), + ).rejects.toThrow("Environment is required"); }); }); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/applications-map-add.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/applications-map-add.test.ts index 99b08ca9..02fd8867 100644 --- a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/applications-map-add.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/applications-map-add.test.ts @@ -1,109 +1,71 @@ import * as cli from "src/entrypoint/cli/applications-map-add"; import * as helper from "src/entrypoint/cli/helper"; -import { - captureCliConsoleState, - expectWrappedCliError, - resetCliConsoleState, - restoreCliConsoleState, -} from "src/__tests__/entrypoint/cli/test-utils"; -const mockAddApplication = jest.fn(); const mockFormatApplicationsMap = jest.fn(); - -jest.mock("src/entrypoint/cli/helper", () => ({ - ...jest.requireActual("src/entrypoint/cli/helper"), - createSsmApplicationsMapRepository: jest.fn(), -})); - +jest.mock("src/entrypoint/cli/helper", () => { + const actual = jest.requireActual("src/entrypoint/cli/helper"); + return { + ...actual, + createS3ApplicationsMapRepository: jest.fn(), + }; +}); jest.mock("src/format", () => ({ - ...jest.requireActual("src/format"), formatApplicationsMap: (...args: unknown[]) => mockFormatApplicationsMap(...args), })); -const mockCreateSsmApplicationsMapRepository = - helper.createSsmApplicationsMapRepository as jest.Mock; +const mockCreateS3ApplicationsMapRepository = + helper.createS3ApplicationsMapRepository as jest.Mock; describe("applications-map-add CLI", () => { - const originalCliConsoleState = captureCliConsoleState(); - const baseArgs = [ "node", "script", "--client-id", - "client-1", + "test-client", "--application-id", - "app-1", - "--parameter-name", - "/nhs/dev/callbacks/applications-map", + "app-123", + "--environment", + "dev", ]; - const resultMap = new Map([["client-1", "app-1"]]); - beforeEach(() => { - mockAddApplication.mockReset(); - mockAddApplication.mockResolvedValue(resultMap); + mockCreateS3ApplicationsMapRepository.mockReset(); mockFormatApplicationsMap.mockReset(); - mockFormatApplicationsMap.mockReturnValue("masked-map-output"); - mockCreateSsmApplicationsMapRepository.mockReset(); - mockCreateSsmApplicationsMapRepository.mockReturnValue({ - addApplication: mockAddApplication, + mockCreateS3ApplicationsMapRepository.mockResolvedValue({ + addApplication: jest + .fn() + .mockResolvedValue(new Map([["test-client", "app-123"]])), }); - resetCliConsoleState(); + mockFormatApplicationsMap.mockReturnValue("formatted-output"); }); - afterAll(() => { - restoreCliConsoleState(originalCliConsoleState); - }); + it("adds an application mapping", async () => { + const consoleSpy = jest.spyOn(console, "log").mockImplementation(); - it("adds application and logs output", async () => { await cli.main(baseArgs); - expect(mockCreateSsmApplicationsMapRepository).toHaveBeenCalledWith( + expect(mockCreateS3ApplicationsMapRepository).toHaveBeenCalledWith( expect.objectContaining({ - "client-id": "client-1", - "application-id": "app-1", - "parameter-name": "/nhs/dev/callbacks/applications-map", + "client-id": "test-client", + "application-id": "app-123", + environment: "dev", }), ); - expect(mockAddApplication).toHaveBeenCalledWith("client-1", "app-1", false); - expect(console.log).toHaveBeenCalledWith( - "Applications map updated for client 'client-1'.", + expect(consoleSpy).toHaveBeenCalledWith( + "Applications map updated for client 'test-client'.", ); - expect(mockFormatApplicationsMap).toHaveBeenCalledWith(resultMap); - expect(console.log).toHaveBeenCalledWith("masked-map-output"); + consoleSpy.mockRestore(); }); - it("does not log application-id", async () => { - await cli.main(baseArgs); - - const logMessages = (console.log as jest.Mock).mock.calls.flat(); - expect(logMessages).not.toContain("app-1"); - }); + it("shows dry-run message when --dry-run is set", async () => { + const consoleSpy = jest.spyOn(console, "log").mockImplementation(); - it("does not log dry-run message when dry-run is false", async () => { - await cli.main(baseArgs); - - expect(console.log).not.toHaveBeenCalledWith( - "Dry run — no changes written to SSM.", - ); - }); - - it("passes dry-run flag to repository and logs dry-run message", async () => { await cli.main([...baseArgs, "--dry-run"]); - expect(mockAddApplication).toHaveBeenCalledWith("client-1", "app-1", true); - expect(console.log).toHaveBeenCalledWith( - "Dry run — no changes written to SSM.", + expect(consoleSpy).toHaveBeenCalledWith( + "Dry run — no changes written to S3.", ); - }); - - it("handles errors in wrapped CLI", async () => { - expect.hasAssertions(); - mockCreateSsmApplicationsMapRepository.mockReturnValue({ - addApplication: jest.fn().mockRejectedValue(new Error("Boom")), - }); - - await expectWrappedCliError(cli.main, baseArgs); + consoleSpy.mockRestore(); }); }); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/applications-map-get.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/applications-map-get.test.ts index 3ddb8041..6b0bbe70 100644 --- a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/applications-map-get.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/applications-map-get.test.ts @@ -1,86 +1,56 @@ import * as cli from "src/entrypoint/cli/applications-map-get"; import * as helper from "src/entrypoint/cli/helper"; -import { - captureCliConsoleState, - expectWrappedCliError, - resetCliConsoleState, - restoreCliConsoleState, -} from "src/__tests__/entrypoint/cli/test-utils"; -const mockGetApplication = jest.fn(); - -jest.mock("src/entrypoint/cli/helper", () => ({ - ...jest.requireActual("src/entrypoint/cli/helper"), - createSsmApplicationsMapRepository: jest.fn(), -})); +jest.mock("src/entrypoint/cli/helper", () => { + const actual = jest.requireActual("src/entrypoint/cli/helper"); + return { + ...actual, + createS3ApplicationsMapRepository: jest.fn(), + }; +}); -const mockCreateSsmApplicationsMapRepository = - helper.createSsmApplicationsMapRepository as jest.Mock; +const mockCreateS3ApplicationsMapRepository = + helper.createS3ApplicationsMapRepository as jest.Mock; describe("applications-map-get CLI", () => { - const originalCliConsoleState = captureCliConsoleState(); - const baseArgs = [ "node", "script", "--client-id", - "client-1", - "--parameter-name", - "/nhs/dev/callbacks/applications-map", + "test-client", + "--environment", + "dev", ]; beforeEach(() => { - mockGetApplication.mockReset(); - mockCreateSsmApplicationsMapRepository.mockReset(); - mockCreateSsmApplicationsMapRepository.mockReturnValue({ - getApplication: mockGetApplication, + mockCreateS3ApplicationsMapRepository.mockReset(); + mockCreateS3ApplicationsMapRepository.mockResolvedValue({ + getApplication: jest.fn().mockResolvedValue("app-id-123"), }); - resetCliConsoleState(); }); - afterAll(() => { - restoreCliConsoleState(originalCliConsoleState); - }); - - it("prints the application ID when mapping exists", async () => { - mockGetApplication.mockResolvedValue("app-1"); + it("outputs the application ID for a known client", async () => { + const consoleSpy = jest.spyOn(console, "log").mockImplementation(); await cli.main(baseArgs); - expect(mockCreateSsmApplicationsMapRepository).toHaveBeenCalledWith( + expect(mockCreateS3ApplicationsMapRepository).toHaveBeenCalledWith( expect.objectContaining({ - "client-id": "client-1", - "parameter-name": "/nhs/dev/callbacks/applications-map", + "client-id": "test-client", + environment: "dev", }), ); - expect(mockGetApplication).toHaveBeenCalledWith("client-1"); - expect(console.log).toHaveBeenCalledWith("app-1"); - }); - - it("does not log the application-id in other messages", async () => { - mockGetApplication.mockResolvedValue("app-1"); - - await cli.main(baseArgs); - - const logMessages = (console.log as jest.Mock).mock.calls.flat(); - expect(logMessages).toEqual(["app-1"]); + expect(consoleSpy).toHaveBeenCalledWith("app-id-123"); + consoleSpy.mockRestore(); }); it("throws when no mapping exists for the client", async () => { - expect.hasAssertions(); - mockGetApplication.mockResolvedValue(undefined); + mockCreateS3ApplicationsMapRepository.mockResolvedValue({ + getApplication: jest.fn().mockResolvedValue(undefined), + }); - await expectWrappedCliError( - cli.main, - baseArgs, - "No application mapping exists for client: client-1", + await expect(cli.main(baseArgs)).rejects.toThrow( + "No application mapping exists for client: test-client", ); }); - - it("handles repository errors", async () => { - expect.hasAssertions(); - mockGetApplication.mockRejectedValue(new Error("Boom")); - - await expectWrappedCliError(cli.main, baseArgs); - }); }); diff --git a/tools/client-subscriptions-management/src/__tests__/repository/ssm-applications-map.test.ts b/tools/client-subscriptions-management/src/__tests__/repository/ssm-applications-map.test.ts index afb94e41..fb3d4027 100644 --- a/tools/client-subscriptions-management/src/__tests__/repository/ssm-applications-map.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/repository/ssm-applications-map.test.ts @@ -1,71 +1,85 @@ import { - GetParameterCommand, - PutParameterCommand, - type SSMClient, -} from "@aws-sdk/client-ssm"; -import SsmApplicationsMapRepository from "src/repository/ssm-applications-map"; + GetObjectCommand, + NoSuchKey, + PutObjectCommand, + type S3Client, +} from "@aws-sdk/client-s3"; +import { Readable } from "node:stream"; +import type { SdkStream } from "@smithy/types"; +import S3ApplicationsMapRepository from "src/repository/s3-applications-map"; + +function createS3Body(content: string): { + Body: SdkStream; +} { + const stream = Readable.from([content]) as SdkStream; + stream.transformToString = jest.fn().mockResolvedValue(content); + return { Body: stream }; +} const createRepository = (send: jest.Mock = jest.fn()) => { - const client = { send } as unknown as SSMClient; + const client = { send } as unknown as S3Client; return { - repository: new SsmApplicationsMapRepository(client, "/test/param"), + repository: new S3ApplicationsMapRepository( + client, + "test-bucket", + "dev/applications-map.json", + ), send, }; }; -describe("SsmApplicationsMapRepository", () => { +describe("S3ApplicationsMapRepository", () => { describe("getApplication", () => { it("returns the application ID for an existing client", async () => { const { repository, send } = createRepository(); - send.mockResolvedValueOnce({ - Parameter: { - Value: JSON.stringify({ "client-1": "app-1", "client-2": "app-2" }), - }, - }); + send.mockResolvedValueOnce( + createS3Body( + JSON.stringify({ "client-1": "app-1", "client-2": "app-2" }), + ), + ); const result = await repository.getApplication("client-1"); - expect(send).toHaveBeenCalledWith(expect.any(GetParameterCommand)); + expect(send).toHaveBeenCalledWith(expect.any(GetObjectCommand)); expect(result).toBe("app-1"); }); it("returns undefined when the client is not in the map", async () => { const { repository, send } = createRepository(); - send.mockResolvedValueOnce({ - Parameter: { Value: JSON.stringify({ "other-client": "app-1" }) }, - }); + send.mockResolvedValueOnce( + createS3Body(JSON.stringify({ "other-client": "app-1" })), + ); const result = await repository.getApplication("client-1"); expect(result).toBeUndefined(); }); - it("returns undefined when parameter does not exist", async () => { + it("returns undefined when S3 object does not exist", async () => { const { repository, send } = createRepository(); - const error = Object.assign(new Error("not found"), { - name: "ParameterNotFound", - }); - send.mockRejectedValueOnce(error); + send.mockRejectedValueOnce( + new NoSuchKey({ message: "not found", $metadata: {} }), + ); const result = await repository.getApplication("client-1"); expect(result).toBeUndefined(); }); - it("returns undefined when parameter has no value", async () => { + it("returns undefined when S3 object body is empty", async () => { const { repository, send } = createRepository(); - send.mockResolvedValueOnce({ Parameter: {} }); + const stream = Readable.from([]) as SdkStream; + stream.transformToString = jest.fn().mockResolvedValue(""); + send.mockResolvedValueOnce({ Body: stream }); const result = await repository.getApplication("client-1"); expect(result).toBeUndefined(); }); - it("rethrows unexpected SSM errors", async () => { + it("rethrows unexpected S3 errors", async () => { const { repository, send } = createRepository(); - send.mockRejectedValueOnce( - Object.assign(new Error("Network failure"), { name: "NetworkError" }), - ); + send.mockRejectedValueOnce(new Error("Network failure")); await expect(repository.getApplication("client-1")).rejects.toThrow( "Network failure", @@ -77,17 +91,15 @@ describe("SsmApplicationsMapRepository", () => { it("reads existing map, merges new entry, and writes back", async () => { const { repository, send } = createRepository(); send - .mockResolvedValueOnce({ - Parameter: { - Value: JSON.stringify({ "existing-client": "existing-app" }), - }, - }) + .mockResolvedValueOnce( + createS3Body(JSON.stringify({ "existing-client": "existing-app" })), + ) .mockResolvedValueOnce({}); const result = await repository.addApplication("client-1", "app-1"); - expect(send).toHaveBeenNthCalledWith(1, expect.any(GetParameterCommand)); - expect(send).toHaveBeenNthCalledWith(2, expect.any(PutParameterCommand)); + expect(send).toHaveBeenNthCalledWith(1, expect.any(GetObjectCommand)); + expect(send).toHaveBeenNthCalledWith(2, expect.any(PutObjectCommand)); expect(result).toEqual( new Map([ ["existing-client", "existing-app"], @@ -96,12 +108,13 @@ describe("SsmApplicationsMapRepository", () => { ); }); - it("starts from empty map when parameter does not exist", async () => { + it("starts from empty map when S3 object does not exist", async () => { const { repository, send } = createRepository(); - const error = Object.assign(new Error("not found"), { - name: "ParameterNotFound", - }); - send.mockRejectedValueOnce(error).mockResolvedValueOnce({}); + send + .mockRejectedValueOnce( + new NoSuchKey({ message: "not found", $metadata: {} }), + ) + .mockResolvedValueOnce({}); const result = await repository.addApplication("client-1", "app-1"); @@ -109,9 +122,11 @@ describe("SsmApplicationsMapRepository", () => { expect(send).toHaveBeenCalledTimes(2); }); - it("starts from empty map when parameter has no value", async () => { + it("starts from empty map when S3 object body is empty", async () => { const { repository, send } = createRepository(); - send.mockResolvedValueOnce({ Parameter: {} }).mockResolvedValueOnce({}); + const stream = Readable.from([]) as SdkStream; + stream.transformToString = jest.fn().mockResolvedValue(""); + send.mockResolvedValueOnce({ Body: stream }).mockResolvedValueOnce({}); const result = await repository.addApplication("client-1", "app-1"); @@ -121,9 +136,9 @@ describe("SsmApplicationsMapRepository", () => { it("overwrites an existing client entry", async () => { const { repository, send } = createRepository(); send - .mockResolvedValueOnce({ - Parameter: { Value: JSON.stringify({ "client-1": "old-app" }) }, - }) + .mockResolvedValueOnce( + createS3Body(JSON.stringify({ "client-1": "old-app" })), + ) .mockResolvedValueOnce({}); const result = await repository.addApplication("client-1", "new-app"); @@ -133,9 +148,7 @@ describe("SsmApplicationsMapRepository", () => { it("skips the put when dry-run is true", async () => { const { repository, send } = createRepository(); - send.mockResolvedValueOnce({ - Parameter: { Value: JSON.stringify({}) }, - }); + send.mockResolvedValueOnce(createS3Body(JSON.stringify({}))); const result = await repository.addApplication("client-1", "app-1", true); @@ -143,11 +156,9 @@ describe("SsmApplicationsMapRepository", () => { expect(result).toEqual(new Map([["client-1", "app-1"]])); }); - it("rethrows unexpected SSM errors", async () => { + it("rethrows unexpected S3 errors", async () => { const { repository, send } = createRepository(); - send.mockRejectedValueOnce( - Object.assign(new Error("Network failure"), { name: "NetworkError" }), - ); + send.mockRejectedValueOnce(new Error("Network failure")); await expect( repository.addApplication("client-1", "app-1"), diff --git a/tools/client-subscriptions-management/src/aws.ts b/tools/client-subscriptions-management/src/aws.ts index 5599b50b..8dbb5f46 100644 --- a/tools/client-subscriptions-management/src/aws.ts +++ b/tools/client-subscriptions-management/src/aws.ts @@ -1,9 +1,8 @@ import { S3Client } from "@aws-sdk/client-s3"; -import { SSMClient } from "@aws-sdk/client-ssm"; import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts"; import { fromIni } from "@aws-sdk/credential-providers"; import { ClientSubscriptionRepository } from "src/repository/client-subscriptions"; -import SsmApplicationsMapRepository from "src/repository/ssm-applications-map"; +import S3ApplicationsMapRepository from "src/repository/s3-applications-map"; import { S3Repository } from "src/repository/s3"; export const resolveProfile = ( @@ -28,8 +27,7 @@ export const deriveBucketName = ( accountId: string, environment: string, region: string, -): string => - `nhs-${accountId}-${region}-${environment}-callbacks-subscription-config`; +): string => `nhs-${accountId}-${region}-${environment}-cb-subscription-config`; export const resolveRegion = ( regionArg?: string, @@ -90,48 +88,57 @@ export const createRepository = (options: { return new ClientSubscriptionRepository(s3Repository); }; -export const createSsmClient = ( - region?: string, - profile?: string, - env: NodeJS.ProcessEnv = process.env, -): SSMClient => { - const endpoint = env.AWS_ENDPOINT_URL; - const credentials = profile ? fromIni({ profile }) : undefined; - return new SSMClient({ region, endpoint, credentials }); -}; +export const deriveApplicationsMapBucketName = ( + accountId: string, + region: string, +): string => `nhs-${accountId}-${region}-main-acct-clie-apps-map`; -export const deriveParameterName = (environment: string): string => - `/nhs/${environment}/callbacks/applications-map`; +export const deriveApplicationsMapKey = (environment: string): string => + `${environment}/applications-map.json`; -export const resolveParameterName = (args: { - parameterName?: string; +export const resolveApplicationsMapLocation = async (args: { + bucket?: string; + key?: string; environment?: string; - env?: NodeJS.ProcessEnv; -}): string => { - const { env = process.env, environment, parameterName } = args; - - if (parameterName) { - return parameterName; - } + profile?: string; + region?: string; +}): Promise<{ bucket: string; key: string }> => { + const { bucket, environment, key, profile, region } = args; - const resolvedEnvironment = environment ?? env.ENVIRONMENT; - if (!resolvedEnvironment) { + const resolvedEnvironment = environment ?? process.env.ENVIRONMENT; + if (!resolvedEnvironment && (!bucket || !key)) { throw new Error( - "Environment is required to derive parameter name. Please provide via --environment or ENVIRONMENT env var.", + "Environment is required to derive applications map location. Provide via --environment or ENVIRONMENT env var.", ); } - return deriveParameterName(resolvedEnvironment); + const resolvedKey = key ?? deriveApplicationsMapKey(resolvedEnvironment!); + + if (bucket) { + return { bucket, key: resolvedKey }; + } + + const resolvedRegion = resolveRegion(region) ?? "eu-west-2"; + const resolvedAccountId = + process.env.AWS_ACCOUNT_ID ?? + (await resolveAccountId(profile, resolvedRegion)); + + return { + bucket: deriveApplicationsMapBucketName(resolvedAccountId, resolvedRegion), + key: resolvedKey, + }; }; -export const createSsmApplicationsMapRepository = (options: { - parameterName: string; +export const createS3ApplicationsMapRepository = (options: { + bucket: string; + key: string; region?: string; profile?: string; -}): SsmApplicationsMapRepository => - new SsmApplicationsMapRepository( - createSsmClient(options.region, options.profile), - options.parameterName, +}): S3ApplicationsMapRepository => + new S3ApplicationsMapRepository( + createS3Client(options.region, options.profile), + options.bucket, + options.key, ); -export { default as SsmApplicationsMapRepository } from "src/repository/ssm-applications-map"; +export { default as S3ApplicationsMapRepository } from "src/repository/s3-applications-map"; diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/applications-map-add.ts b/tools/client-subscriptions-management/src/entrypoint/cli/applications-map-add.ts index a98e574f..365d6e51 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/applications-map-add.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/applications-map-add.ts @@ -1,20 +1,20 @@ import type { Argv } from "yargs"; import { + type ApplicationsMapCliArgs, type CliCommand, type ClientCliArgs, - type SsmCliArgs, type WriteCliArgs, + applicationsMapOptions, clientIdOption, commonOptions, - createSsmApplicationsMapRepository, - parameterNameOption, + createS3ApplicationsMapRepository, runCommand, writeOptions, } from "src/entrypoint/cli/helper"; import { formatApplicationsMap } from "src/format"; type ApplicationsMapAddArgs = ClientCliArgs & - SsmCliArgs & + ApplicationsMapCliArgs & WriteCliArgs & { "application-id": string; }; @@ -23,7 +23,7 @@ export const builder = (yargs: Argv) => yargs.options({ ...commonOptions, ...clientIdOption, - ...parameterNameOption, + ...applicationsMapOptions, ...writeOptions, "application-id": { type: "string", @@ -35,7 +35,7 @@ export const builder = (yargs: Argv) => export const handler: CliCommand["handler"] = async ( argv, ) => { - const repository = createSsmApplicationsMapRepository(argv); + const repository = await createS3ApplicationsMapRepository(argv); const result = await repository.addApplication( argv["client-id"], argv["application-id"], @@ -43,14 +43,14 @@ export const handler: CliCommand["handler"] = async ( ); console.log(`Applications map updated for client '${argv["client-id"]}'.`); if (argv["dry-run"]) { - console.log("Dry run — no changes written to SSM."); + console.log("Dry run — no changes written to S3."); } console.log(formatApplicationsMap(result)); }; export const command: CliCommand = { command: "applications-map-add", - describe: "Add or update a client-to-application-ID mapping in SSM", + describe: "Add or update a client-to-application-ID mapping in S3", builder, handler, }; diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/applications-map-get.ts b/tools/client-subscriptions-management/src/entrypoint/cli/applications-map-get.ts index 5ffe2192..3e22db39 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/applications-map-get.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/applications-map-get.ts @@ -1,28 +1,28 @@ import type { Argv } from "yargs"; import { + type ApplicationsMapCliArgs, type CliCommand, type ClientCliArgs, - type SsmCliArgs, + applicationsMapOptions, clientIdOption, commonOptions, - createSsmApplicationsMapRepository, - parameterNameOption, + createS3ApplicationsMapRepository, runCommand, } from "src/entrypoint/cli/helper"; -type ApplicationsMapGetArgs = ClientCliArgs & SsmCliArgs; +type ApplicationsMapGetArgs = ClientCliArgs & ApplicationsMapCliArgs; export const builder = (yargs: Argv) => yargs.options({ ...commonOptions, ...clientIdOption, - ...parameterNameOption, + ...applicationsMapOptions, }); export const handler: CliCommand["handler"] = async ( argv, ) => { - const repository = createSsmApplicationsMapRepository(argv); + const repository = await createS3ApplicationsMapRepository(argv); const applicationId = await repository.getApplication(argv["client-id"]); if (applicationId) { diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts index 23070926..10c463ea 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts @@ -4,9 +4,9 @@ import type { } from "@nhs-notify-client-callbacks/models"; import { createRepository as createRepositoryFromOptions, - createSsmApplicationsMapRepository as createSsmApplicationsMapRepositoryFromOptions, + createS3ApplicationsMapRepository as createS3ApplicationsMapRepositoryFromOptions, + resolveApplicationsMapLocation, resolveBucketName, - resolveParameterName as resolveParameterNameFromAws, resolveProfile, resolveRegion, } from "src/aws"; @@ -145,28 +145,41 @@ export const writeOptions = { }, }; -export type SsmCliArgs = CommonCliArgs & { - "parameter-name"?: string; +export type ApplicationsMapCliArgs = CommonCliArgs & { + "applications-map-bucket"?: string; + "applications-map-key"?: string; }; -export const parameterNameOption = { - "parameter-name": { +export const applicationsMapOptions = { + "applications-map-bucket": { type: "string" as const, demandOption: false as const, description: - "Explicit SSM parameter name for the applications map (overrides derived name)", + "Explicit S3 bucket for the applications map (overrides derived name)", + }, + "applications-map-key": { + type: "string" as const, + demandOption: false as const, + description: + "Explicit S3 key for the applications map (overrides derived key)", }, }; -export const createSsmApplicationsMapRepository = (argv: SsmCliArgs) => { +export const createS3ApplicationsMapRepository = async ( + argv: ApplicationsMapCliArgs, +) => { const region = resolveRegion(argv.region); const profile = resolveProfile(argv.profile); - const parameterName = resolveParameterNameFromAws({ - parameterName: argv["parameter-name"], + const { bucket, key } = await resolveApplicationsMapLocation({ + bucket: argv["applications-map-bucket"], + key: argv["applications-map-key"], environment: argv.environment, + region, + profile, }); - return createSsmApplicationsMapRepositoryFromOptions({ - parameterName, + return createS3ApplicationsMapRepositoryFromOptions({ + bucket, + key, region, profile, }); diff --git a/tools/client-subscriptions-management/src/repository/s3-applications-map.ts b/tools/client-subscriptions-management/src/repository/s3-applications-map.ts new file mode 100644 index 00000000..c61354db --- /dev/null +++ b/tools/client-subscriptions-management/src/repository/s3-applications-map.ts @@ -0,0 +1,59 @@ +import { + GetObjectCommand, + NoSuchKey, + PutObjectCommand, + type S3Client, +} from "@aws-sdk/client-s3"; + +export default class S3ApplicationsMapRepository { + constructor( + private readonly client: S3Client, + private readonly bucket: string, + private readonly key: string, + ) {} + + async getApplication(clientId: string): Promise { + const map = await this.loadMap(); + if (!map) return undefined; + // eslint-disable-next-line security/detect-object-injection + return map[clientId]; + } + + async addApplication( + clientId: string, + applicationId: string, + dryRun = false, + ): Promise> { + const current = (await this.loadMap()) ?? {}; + const updated = { ...current, [clientId]: applicationId }; + + if (!dryRun) { + await this.client.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: this.key, + Body: JSON.stringify(updated), + ContentType: "application/json", + }), + ); + } + + return new Map(Object.entries(updated)); + } + + private async loadMap(): Promise | undefined> { + try { + const response = await this.client.send( + new GetObjectCommand({ Bucket: this.bucket, Key: this.key }), + ); + const body = await response.Body?.transformToString(); + if (!body) return undefined; + return JSON.parse(body) as Record; + } catch (error) { + if (error instanceof NoSuchKey) { + return undefined; + } + throw error; + } + } +} diff --git a/tools/client-subscriptions-management/src/repository/ssm-applications-map.ts b/tools/client-subscriptions-management/src/repository/ssm-applications-map.ts deleted file mode 100644 index a7edb3f6..00000000 --- a/tools/client-subscriptions-management/src/repository/ssm-applications-map.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - GetParameterCommand, - PutParameterCommand, - type SSMClient, -} from "@aws-sdk/client-ssm"; - -export default class SsmApplicationsMapRepository { - constructor( - private readonly client: SSMClient, - private readonly parameterName: string, - ) {} - - async getApplication(clientId: string): Promise { - try { - const response = await this.client.send( - new GetParameterCommand({ - Name: this.parameterName, - WithDecryption: true, - }), - ); - if (response.Parameter?.Value) { - const map = JSON.parse(response.Parameter.Value) as Record< - string, - string - >; - // eslint-disable-next-line security/detect-object-injection - return map[clientId]; - } - } catch (error) { - if (error instanceof Error && error.name !== "ParameterNotFound") { - throw error; - } - } - return undefined; - } - - async addApplication( - clientId: string, - applicationId: string, - dryRun = false, - ): Promise> { - let current: Record = {}; - - try { - const response = await this.client.send( - new GetParameterCommand({ - Name: this.parameterName, - WithDecryption: true, - }), - ); - if (response.Parameter?.Value) { - current = JSON.parse(response.Parameter.Value) as Record< - string, - string - >; - } - } catch (error) { - if (error instanceof Error && error.name !== "ParameterNotFound") { - throw error; - } - } - - const updated = { ...current, [clientId]: applicationId }; - - if (!dryRun) { - await this.client.send( - new PutParameterCommand({ - Name: this.parameterName, - Value: JSON.stringify(updated), - Type: "SecureString", - Overwrite: true, - }), - ); - } - - return new Map(Object.entries(updated)); - } -}