diff --git a/docs/configuration.md b/docs/configuration.md index d207f23c70..ca3fafb6b3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -33,12 +33,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..82eb2a0467 --- /dev/null +++ b/modules/multi-runner/tests/webhook-matcher-config.tftest.hcl @@ -0,0 +1,218 @@ +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 { + 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 = { + 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"] == var.previous_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 { + 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 = { + 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"] != 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 = { + 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"] != var.previous_matcher_config_path + error_message = "A changed parameter tier 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..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({ @@ -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..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({ @@ -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..5677290fe3 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(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}*" } +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 } }