From 03890f82f67fa1a4d658dd7d11cd1d015f8441e3 Mon Sep 17 00:00:00 2001 From: Anatoli Tsikhamirau Date: Wed, 14 Oct 2026 13:49:52 +0200 Subject: [PATCH 1/3] fix(webhook): replace matcher config chunks before deleting the old ones Chunk names now contain a hash of the matcher config and use create_before_destroy. A changed config is written to a new set of parameters, the Lambdas are switched to it, and only then is the old set deleted, including chunks orphaned when the config gets smaller. The IAM policies grant the matcher config path once, so they do not change with each config version. Fixes #5520 --- docs/configuration.md | 12 +- .../tests/webhook-matcher-config.tftest.hcl | 166 ++++++++++++++++++ modules/webhook/README.md | 2 + modules/webhook/direct/README.md | 2 +- modules/webhook/direct/variables.tf | 1 + modules/webhook/direct/webhook.tf | 2 +- modules/webhook/eventbridge/README.md | 2 +- modules/webhook/eventbridge/dispatcher.tf | 2 +- modules/webhook/eventbridge/variables.tf | 3 +- modules/webhook/webhook.tf | 19 +- 10 files changed, 198 insertions(+), 13 deletions(-) create mode 100644 modules/multi-runner/tests/webhook-matcher-config.tftest.hcl diff --git a/docs/configuration.md b/docs/configuration.md index 81ae07d5f8..aa043b69ad 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,12 +27,12 @@ Furthermore, to accommodate larger JIT configurations or other stored values, th - **Cost Implications**: While the 'Standard' tier is generally free for a certain number of parameters and operations, the 'Advanced' tier incurs costs. These costs are typically pro-rated per hour for each parameter stored using the Advanced tier. For detailed and up-to-date pricing, please refer to the [AWS Systems Manager Pricing page](https://aws.amazon.com/systems-manager/pricing/#Parameter_Store). - **Housekeeping Recommendation**: The last sentence of the "AWS SSM Parameters" section already mentions that "data not deleted after a day will be deleted by a SSM housekeeper lambda." It is crucial to ensure this or a similar housekeeping mechanism is active and correctly configured, especially considering the potential costs associated with 'Advanced' tier parameters. This utility should identify and delete any orphaned parameters to help manage costs and maintain a clean SSM environment. -| Path | Description | -| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ssm_paths.root/var.prefix?/app/` | App secrets used by Lambda's | -| `ssm_paths.root/var.prefix?/runners/config/` | Configuration parameters used by runner start script | -| `ssm_paths.root/var.prefix?/runners/tokens/` | Either JIT configuration (ephemeral runners) or registration tokens (non ephemeral runners) generated by the control plane (scale-up lambda), and consumed by the start script on the runner to activate / register the runner. | -| `ssm_paths.root/var.prefix?/webhook/runner-matcher-config` | Runner matcher config used by webhook to decide the target for the webhook event. | +| Path | Description | +| ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ssm_paths.root/var.prefix?/app/` | App secrets used by Lambda's | +| `ssm_paths.root/var.prefix?/runners/config/` | Configuration parameters used by runner start script | +| `ssm_paths.root/var.prefix?/runners/tokens/` | Either JIT configuration (ephemeral runners) or registration tokens (non ephemeral runners) generated by the control plane (scale-up lambda), and consumed by the start script on the runner to activate / register the runner. | +| `ssm_paths.root/var.prefix?/webhook/runner-matcher-config--` | Runner matcher config used by webhook to decide the target for the webhook event. Split into one or more parameters. The names change with the content, so a changed config is written to new parameters before the old ones are deleted. | Available configuration parameters: diff --git a/modules/multi-runner/tests/webhook-matcher-config.tftest.hcl b/modules/multi-runner/tests/webhook-matcher-config.tftest.hcl new file mode 100644 index 0000000000..4f6b962cbf --- /dev/null +++ b/modules/multi-runner/tests/webhook-matcher-config.tftest.hcl @@ -0,0 +1,166 @@ +mock_provider "aws" { + mock_data "aws_caller_identity" { + defaults = { + account_id = "123456789012" + } + } + + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + } + + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/test-role" + } + } + + mock_resource "aws_cloudwatch_event_bus" { + defaults = { + arn = "arn:aws:events:eu-west-1:123456789012:event-bus/test" + } + } + + mock_resource "aws_cloudwatch_event_rule" { + defaults = { + arn = "arn:aws:events:eu-west-1:123456789012:rule/test" + } + } + + mock_resource "aws_lambda_function" { + defaults = { + arn = "arn:aws:lambda:eu-west-1:123456789012:function:test" + } + } + + mock_resource "aws_sqs_queue" { + defaults = { + arn = "arn:aws:sqs:eu-west-1:123456789012:test" + } + } + + mock_resource "aws_s3_bucket" { + defaults = { + arn = "arn:aws:s3:::test-runner-binaries" + id = "test-runner-binaries" + } + } + + mock_resource "aws_apigatewayv2_api" { + defaults = { + execution_arn = "arn:aws:execute-api:eu-west-1:123456789012:test" + } + } +} + +mock_provider "random" {} +mock_provider "null" {} + +variables { + aws_region = "eu-west-1" + vpc_id = "vpc-test" + subnet_ids = ["subnet-test"] + + multi_runner_config = {} + + github_app = { + key_base64_ssm = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/tests/github-app/key" + name = "/tests/github-app/key" + } + id_ssm = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/tests/github-app/id" + name = "/tests/github-app/id" + } + webhook_secret_ssm = { + arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/tests/github-app/webhook-secret" + name = "/tests/github-app/webhook-secret" + } + } + + lambda_s3_bucket = "test-lambda-artifacts" + runners_lambda_zip = "README.md" + runners_lambda_s3_key = "runners.zip" + webhook_lambda_s3_key = "webhook.zip" + syncer_lambda_s3_key = "runner-binaries-syncer.zip" +} + +run "matcher_config_chunk_names_include_config_hash" { + command = apply + + variables { + multi_runner_config = { + stable = { + runner_config = { + runner_os = "linux" + runner_architecture = "x64" + instance_types = ["m5.large"] + runners_maximum_count = 1 + } + matcherConfig = { + labelMatchers = [["first-label"]] + } + } + } + } + + assert { + condition = alltrue([ + for path in split(":", output.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"]) : + can(regex("/runner-matcher-config-[0-9a-f]{12}-[0-9]+$", path)) + ]) + error_message = "Matcher config chunk names must contain the config hash and the chunk index." + } +} + +run "unchanged_matcher_config_keeps_chunk_names" { + command = apply + + variables { + multi_runner_config = { + stable = { + runner_config = { + runner_os = "linux" + runner_architecture = "x64" + instance_types = ["m5.large"] + runners_maximum_count = 1 + } + matcherConfig = { + labelMatchers = [["first-label"]] + } + } + } + } + + assert { + condition = output.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] == run.matcher_config_chunk_names_include_config_hash.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] + error_message = "An unchanged matcher config must keep the same chunk names." + } +} + +run "changed_matcher_config_uses_new_chunk_names" { + command = apply + + variables { + multi_runner_config = { + stable = { + runner_config = { + runner_os = "linux" + runner_architecture = "x64" + instance_types = ["m5.large"] + runners_maximum_count = 1 + } + matcherConfig = { + labelMatchers = [["second-label"]] + } + } + } + } + + assert { + condition = output.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] != run.matcher_config_chunk_names_include_config_hash.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] + error_message = "A changed matcher config must use new chunk names." + } +} diff --git a/modules/webhook/README.md b/modules/webhook/README.md index b4a7d3ba28..6488687bab 100644 --- a/modules/webhook/README.md +++ b/modules/webhook/README.md @@ -61,6 +61,8 @@ yarn run dist | [aws_apigatewayv2_route.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/apigatewayv2_route) | resource | | [aws_apigatewayv2_stage.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/apigatewayv2_stage) | resource | | [aws_ssm_parameter.runner_matcher_config](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | +| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | +| [aws_region.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/region) | data source | ## Inputs diff --git a/modules/webhook/direct/README.md b/modules/webhook/direct/README.md index 67fea936dc..cad59b502b 100644 --- a/modules/webhook/direct/README.md +++ b/modules/webhook/direct/README.md @@ -40,7 +40,7 @@ No modules. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [config](#input\_config) | Configuration object for all variables. |
object({
prefix = string
archive = optional(object({
enable = optional(bool, true)
retention_days = optional(number, 7)
}), {})
tags = optional(map(string), {})

lambda_subnet_ids = optional(list(string), [])
lambda_security_group_ids = optional(list(string), [])
sqs_job_queues_arns = list(string)
lambda_zip = optional(string, null)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 10)
role_permissions_boundary = optional(string, null)
role_path = optional(string, null)
logging_retention_in_days = optional(number, 180)
logging_kms_key_id = optional(string, null)
log_class = optional(string, "STANDARD")
lambda_s3_bucket = optional(string, null)
lambda_s3_key = optional(string, null)
lambda_s3_object_version = optional(string, null)
lambda_apigateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
repository_white_list = optional(list(string), [])
queue_selection_strategy = optional(string, "first")
storage_provider = object({
aws = object({
ssm = object({
kms_key_id = optional(string, null)
})
})
})
log_level = optional(string, "info")
lambda_runtime = optional(string, "nodejs24.x")
aws_partition = optional(string, "aws")
lambda_architecture = optional(string, "arm64")
github_app_parameters = object({
webhook_secret = map(string)
})
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
lambda_tags = optional(map(string), {})
api_gw_source_arn = string
ssm_parameter_runner_matcher_config = list(object({
name = string
arn = string
version = string
}))
})
| n/a | yes | +| [config](#input\_config) | Configuration object for all variables. |
object({
prefix = string
archive = optional(object({
enable = optional(bool, true)
retention_days = optional(number, 7)
}), {})
tags = optional(map(string), {})

lambda_subnet_ids = optional(list(string), [])
lambda_security_group_ids = optional(list(string), [])
sqs_job_queues_arns = list(string)
lambda_zip = optional(string, null)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 10)
role_permissions_boundary = optional(string, null)
role_path = optional(string, null)
logging_retention_in_days = optional(number, 180)
logging_kms_key_id = optional(string, null)
log_class = optional(string, "STANDARD")
lambda_s3_bucket = optional(string, null)
lambda_s3_key = optional(string, null)
lambda_s3_object_version = optional(string, null)
lambda_apigateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
repository_white_list = optional(list(string), [])
queue_selection_strategy = optional(string, "first")
storage_provider = object({
aws = object({
ssm = object({
kms_key_id = optional(string, null)
})
})
})
log_level = optional(string, "info")
lambda_runtime = optional(string, "nodejs24.x")
aws_partition = optional(string, "aws")
lambda_architecture = optional(string, "arm64")
github_app_parameters = object({
webhook_secret = map(string)
})
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
lambda_tags = optional(map(string), {})
api_gw_source_arn = string
ssm_parameter_runner_matcher_config = list(object({
name = string
arn = string
version = string
}))
ssm_parameter_runner_matcher_config_arn_pattern = string
})
| n/a | yes | ## Outputs diff --git a/modules/webhook/direct/variables.tf b/modules/webhook/direct/variables.tf index 671c911f70..cf3595bc0f 100644 --- a/modules/webhook/direct/variables.tf +++ b/modules/webhook/direct/variables.tf @@ -54,5 +54,6 @@ variable "config" { arn = string version = string })) + ssm_parameter_runner_matcher_config_arn_pattern = string }) } diff --git a/modules/webhook/direct/webhook.tf b/modules/webhook/direct/webhook.tf index 3a4658ce77..f1c60838f5 100644 --- a/modules/webhook/direct/webhook.tf +++ b/modules/webhook/direct/webhook.tf @@ -141,7 +141,7 @@ resource "aws_iam_role_policy" "webhook_ssm" { resource_arns = jsonencode( concat( [var.config.github_app_parameters.webhook_secret.arn], - [for p in var.config.ssm_parameter_runner_matcher_config : p.arn] + [var.config.ssm_parameter_runner_matcher_config_arn_pattern] ) ) }) diff --git a/modules/webhook/eventbridge/README.md b/modules/webhook/eventbridge/README.md index 987f370b67..9358ba99de 100644 --- a/modules/webhook/eventbridge/README.md +++ b/modules/webhook/eventbridge/README.md @@ -54,7 +54,7 @@ No modules. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [config](#input\_config) | Configuration object for all variables. |
object({
prefix = string
archive = optional(object({
enable = optional(bool, true)
retention_days = optional(number, 7)
}), {})
tags = optional(map(string), {})

lambda_subnet_ids = optional(list(string), [])
lambda_security_group_ids = optional(list(string), [])
sqs_job_queues_arns = list(string)
lambda_zip = optional(string, null)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 10)
role_permissions_boundary = optional(string, null)
role_path = optional(string, null)
logging_retention_in_days = optional(number, 180)
logging_kms_key_id = optional(string, null)
log_class = optional(string, "STANDARD")
lambda_s3_bucket = optional(string, null)
lambda_s3_key = optional(string, null)
lambda_s3_object_version = optional(string, null)
lambda_apigateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
repository_white_list = optional(list(string), [])
queue_selection_strategy = optional(string, "first")
storage_provider = object({
aws = object({
ssm = object({
kms_key_id = optional(string, null)
})
})
})
log_level = optional(string, "info")
lambda_runtime = optional(string, "nodejs24.x")
aws_partition = optional(string, "aws")
lambda_architecture = optional(string, "arm64")
github_app_parameters = object({
webhook_secret = map(string)
})
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
lambda_tags = optional(map(string), {})
api_gw_source_arn = string
ssm_parameter_runner_matcher_config = list(object({
name = string
arn = string
version = string
}))
accept_events = optional(list(string), null)
})
| n/a | yes | +| [config](#input\_config) | Configuration object for all variables. |
object({
prefix = string
archive = optional(object({
enable = optional(bool, true)
retention_days = optional(number, 7)
}), {})
tags = optional(map(string), {})

lambda_subnet_ids = optional(list(string), [])
lambda_security_group_ids = optional(list(string), [])
sqs_job_queues_arns = list(string)
lambda_zip = optional(string, null)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 10)
role_permissions_boundary = optional(string, null)
role_path = optional(string, null)
logging_retention_in_days = optional(number, 180)
logging_kms_key_id = optional(string, null)
log_class = optional(string, "STANDARD")
lambda_s3_bucket = optional(string, null)
lambda_s3_key = optional(string, null)
lambda_s3_object_version = optional(string, null)
lambda_apigateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
repository_white_list = optional(list(string), [])
queue_selection_strategy = optional(string, "first")
storage_provider = object({
aws = object({
ssm = object({
kms_key_id = optional(string, null)
})
})
})
log_level = optional(string, "info")
lambda_runtime = optional(string, "nodejs24.x")
aws_partition = optional(string, "aws")
lambda_architecture = optional(string, "arm64")
github_app_parameters = object({
webhook_secret = map(string)
})
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
lambda_tags = optional(map(string), {})
api_gw_source_arn = string
ssm_parameter_runner_matcher_config = list(object({
name = string
arn = string
version = string
}))
ssm_parameter_runner_matcher_config_arn_pattern = string
accept_events = optional(list(string), null)
})
| n/a | yes | ## Outputs diff --git a/modules/webhook/eventbridge/dispatcher.tf b/modules/webhook/eventbridge/dispatcher.tf index de633b242a..f39cdeb16a 100644 --- a/modules/webhook/eventbridge/dispatcher.tf +++ b/modules/webhook/eventbridge/dispatcher.tf @@ -138,7 +138,7 @@ resource "aws_iam_role_policy" "dispatcher_ssm" { policy = templatefile("${path.module}/../policies/lambda-ssm.json", { resource_arns = jsonencode( concat( - [for p in var.config.ssm_parameter_runner_matcher_config : p.arn] + [var.config.ssm_parameter_runner_matcher_config_arn_pattern] ) ) }) diff --git a/modules/webhook/eventbridge/variables.tf b/modules/webhook/eventbridge/variables.tf index c6b2c660ba..f7ad54152d 100644 --- a/modules/webhook/eventbridge/variables.tf +++ b/modules/webhook/eventbridge/variables.tf @@ -54,6 +54,7 @@ variable "config" { arn = string version = string })) - accept_events = optional(list(string), null) + ssm_parameter_runner_matcher_config_arn_pattern = string + accept_events = optional(list(string), null) }) } diff --git a/modules/webhook/webhook.tf b/modules/webhook/webhook.tf index 418ee9cfb0..728aa83ec3 100644 --- a/modules/webhook/webhook.tf +++ b/modules/webhook/webhook.tf @@ -40,16 +40,29 @@ locals { # Split JSON into chunks safely under the SSM limit matcher_json_chunks = [for i in range(0, length(local.matcher_json), local.chunk_size) : substr(local.matcher_json, i, local.chunk_size)] + + matcher_config_path = "${var.storage_provider.aws.ssm.paths.root}/${var.storage_provider.aws.ssm.paths.webhook}/runner-matcher-config" + matcher_config_hash = substr(sha256(local.matcher_json), 0, 12) + matcher_config_arn_pattern = "arn:${var.aws_partition}:ssm:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:parameter${local.matcher_config_path}*" } +data "aws_caller_identity" "current" {} + +data "aws_region" "current" {} + +# New names per config version, so the Lambdas switch before old chunks are deleted. resource "aws_ssm_parameter" "runner_matcher_config" { count = local.total_chunks - name = "${var.storage_provider.aws.ssm.paths.root}/${var.storage_provider.aws.ssm.paths.webhook}/runner-matcher-config${local.total_chunks > 1 ? "-${count.index}" : ""}" + name = "${local.matcher_config_path}-${local.matcher_config_hash}-${count.index}" type = "String" value = local.matcher_json_chunks[count.index] tier = var.matcher_config_parameter_store_tier tags = var.tags + + lifecycle { + create_before_destroy = true + } } module "direct" { @@ -98,6 +111,7 @@ module "direct" { version = p.version } ] + ssm_parameter_runner_matcher_config_arn_pattern = local.matcher_config_arn_pattern } } @@ -147,7 +161,8 @@ module "eventbridge" { version = p.version } ] - accept_events = var.eventbridge.accept_events + ssm_parameter_runner_matcher_config_arn_pattern = local.matcher_config_arn_pattern + accept_events = var.eventbridge.accept_events } } From 220f851990eb8f7c70d3503183f64ad571322fbe Mon Sep 17 00:00:00 2001 From: Anatoli Tsikhamirau Date: Tue, 27 Oct 2026 22:09:35 +0100 Subject: [PATCH 2/3] fix(webhook): hash chunk layout and order Lambdas after their SSM policy The chunk name hash now covers the parameter tier and the chunk list, so a tier change that changes the chunk count also creates a new parameter set, and an Advanced to Standard downgrade does not recreate a parameter under the same name. The webhook and dispatcher Lambdas now depend on their SSM policy, so on the first upgrade they are not switched to the new names before the policy allows them. --- .../tests/webhook-matcher-config.tftest.hcl | 26 +++++++++++++++++++ modules/webhook/direct/webhook.tf | 2 +- modules/webhook/eventbridge/dispatcher.tf | 2 +- modules/webhook/webhook.tf | 2 +- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/modules/multi-runner/tests/webhook-matcher-config.tftest.hcl b/modules/multi-runner/tests/webhook-matcher-config.tftest.hcl index 4f6b962cbf..c9b78008d1 100644 --- a/modules/multi-runner/tests/webhook-matcher-config.tftest.hcl +++ b/modules/multi-runner/tests/webhook-matcher-config.tftest.hcl @@ -164,3 +164,29 @@ run "changed_matcher_config_uses_new_chunk_names" { error_message = "A changed matcher config must use new chunk names." } } + +run "changed_parameter_tier_uses_new_chunk_names" { + command = apply + + variables { + matcher_config_parameter_store_tier = "Advanced" + multi_runner_config = { + stable = { + runner_config = { + runner_os = "linux" + runner_architecture = "x64" + instance_types = ["m5.large"] + runners_maximum_count = 1 + } + matcherConfig = { + labelMatchers = [["first-label"]] + } + } + } + } + + assert { + condition = output.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] != run.matcher_config_chunk_names_include_config_hash.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] + error_message = "A changed parameter tier must use new chunk names." + } +} diff --git a/modules/webhook/direct/webhook.tf b/modules/webhook/direct/webhook.tf index f1c60838f5..0a05ddd089 100644 --- a/modules/webhook/direct/webhook.tf +++ b/modules/webhook/direct/webhook.tf @@ -16,7 +16,7 @@ resource "aws_lambda_function" "webhook" { memory_size = var.config.lambda_memory_size timeout = var.config.lambda_timeout architectures = [var.config.lambda_architecture] - depends_on = [aws_cloudwatch_log_group.webhook] + depends_on = [aws_cloudwatch_log_group.webhook, aws_iam_role_policy.webhook_ssm] environment { variables = merge({ diff --git a/modules/webhook/eventbridge/dispatcher.tf b/modules/webhook/eventbridge/dispatcher.tf index f39cdeb16a..b92376ba99 100644 --- a/modules/webhook/eventbridge/dispatcher.tf +++ b/modules/webhook/eventbridge/dispatcher.tf @@ -37,7 +37,7 @@ resource "aws_lambda_function" "dispatcher" { memory_size = var.config.lambda_memory_size timeout = var.config.lambda_timeout architectures = [var.config.lambda_architecture] - depends_on = [aws_cloudwatch_log_group.dispatcher] + depends_on = [aws_cloudwatch_log_group.dispatcher, aws_iam_role_policy.dispatcher_ssm] environment { variables = merge({ diff --git a/modules/webhook/webhook.tf b/modules/webhook/webhook.tf index 728aa83ec3..5677290fe3 100644 --- a/modules/webhook/webhook.tf +++ b/modules/webhook/webhook.tf @@ -42,7 +42,7 @@ locals { matcher_json_chunks = [for i in range(0, length(local.matcher_json), local.chunk_size) : substr(local.matcher_json, i, local.chunk_size)] matcher_config_path = "${var.storage_provider.aws.ssm.paths.root}/${var.storage_provider.aws.ssm.paths.webhook}/runner-matcher-config" - matcher_config_hash = substr(sha256(local.matcher_json), 0, 12) + matcher_config_hash = substr(sha256(jsonencode([var.matcher_config_parameter_store_tier, local.matcher_json_chunks])), 0, 12) matcher_config_arn_pattern = "arn:${var.aws_partition}:ssm:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:parameter${local.matcher_config_path}*" } From e19f3170bc44d50f4278c0365bf5d59f270dc802 Mon Sep 17 00:00:00 2001 From: Anatoli Tsikhamirau Date: Tue, 10 Nov 2026 18:30:08 +0100 Subject: [PATCH 3/3] test(multi-runner): make matcher config chunk tests pass on OpenTofu OpenTofu cannot read run outputs inside assert blocks, and it sees outputs from a run two steps back as unknown. Pass the previous path in through the run variables, and compare each run with the run right before it. --- .../tests/webhook-matcher-config.tftest.hcl | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/modules/multi-runner/tests/webhook-matcher-config.tftest.hcl b/modules/multi-runner/tests/webhook-matcher-config.tftest.hcl index c9b78008d1..82eb2a0467 100644 --- a/modules/multi-runner/tests/webhook-matcher-config.tftest.hcl +++ b/modules/multi-runner/tests/webhook-matcher-config.tftest.hcl @@ -119,6 +119,8 @@ run "unchanged_matcher_config_keeps_chunk_names" { command = apply variables { + previous_matcher_config_path = run.matcher_config_chunk_names_include_config_hash.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] + multi_runner_config = { stable = { runner_config = { @@ -135,7 +137,7 @@ run "unchanged_matcher_config_keeps_chunk_names" { } assert { - condition = output.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] == run.matcher_config_chunk_names_include_config_hash.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] + condition = output.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] == var.previous_matcher_config_path error_message = "An unchanged matcher config must keep the same chunk names." } } @@ -144,6 +146,8 @@ run "changed_matcher_config_uses_new_chunk_names" { command = apply variables { + previous_matcher_config_path = run.unchanged_matcher_config_keeps_chunk_names.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] + multi_runner_config = { stable = { runner_config = { @@ -160,15 +164,37 @@ run "changed_matcher_config_uses_new_chunk_names" { } assert { - condition = output.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] != run.matcher_config_chunk_names_include_config_hash.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] + condition = output.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] != var.previous_matcher_config_path error_message = "A changed matcher config must use new chunk names." } } +run "restore_original_matcher_config" { + command = apply + + variables { + multi_runner_config = { + stable = { + runner_config = { + runner_os = "linux" + runner_architecture = "x64" + instance_types = ["m5.large"] + runners_maximum_count = 1 + } + matcherConfig = { + labelMatchers = [["first-label"]] + } + } + } + } +} + run "changed_parameter_tier_uses_new_chunk_names" { command = apply variables { + previous_matcher_config_path = run.restore_original_matcher_config.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] + matcher_config_parameter_store_tier = "Advanced" multi_runner_config = { stable = { @@ -186,7 +212,7 @@ run "changed_parameter_tier_uses_new_chunk_names" { } assert { - condition = output.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] != run.matcher_config_chunk_names_include_config_hash.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] + condition = output.webhook.lambda.environment[0].variables["PARAMETER_RUNNER_MATCHER_CONFIG_PATH"] != var.previous_matcher_config_path error_message = "A changed parameter tier must use new chunk names." } }