diff --git a/README.md b/README.md index 1294941eaa..c70b36a280 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [runner\_binaries\_syncer\_lambda\_timeout](#input\_runner\_binaries\_syncer\_lambda\_timeout) | Time out of the binaries sync lambda in seconds. | `number` | `300` | no | | [runner\_binaries\_syncer\_lambda\_zip](#input\_runner\_binaries\_syncer\_lambda\_zip) | File location of the binaries sync lambda zip file. | `string` | `null` | no | | [runner\_boot\_time\_in\_minutes](#input\_runner\_boot\_time\_in\_minutes) | The minimum time for an EC2 runner to boot and register as a runner. | `number` | `5` | no | -| [runner\_cpu\_options](#input\_runner\_cpu\_options) | The CPU options for the instance. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#cpu-options for details. Note that not all instance types support CPU options, see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-optimize-cpu.html#instance-cpu-options |
object({
core_count = number
threads_per_core = number
}) | `null` | no |
+| [runner\_cpu\_options](#input\_runner\_cpu\_options) | TThe CPU options for the instance. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#cpu-options for details. Note that not all instance types support CPU options, see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-optimize-cpu.html#instance-cpu-options | object({
core_count = number
threads_per_core = number
}) | `null` | no |
| [runner\_credit\_specification](#input\_runner\_credit\_specification) | The credit option for CPU usage of a T instance. Can be unset, "standard" or "unlimited". | `string` | `null` | no |
| [runner\_disable\_default\_labels](#input\_runner\_disable\_default\_labels) | Disable default labels for the runners (os, architecture and `self-hosted`). If enabled, the runner will only have the extra labels provided in `runner_extra_labels`. In case you on own start script is used, this configuration parameter needs to be parsed via SSM. | `bool` | `false` | no |
| [runner\_ec2\_tags](#input\_runner\_ec2\_tags) | Map of tags that will be added to the launch template instance tag specifications. | `map(string)` | `{}` | no |
diff --git a/lambdas/functions/webhook/src/ConfigLoader.test.ts b/lambdas/functions/webhook/src/ConfigLoader.test.ts
index 61ff16ff45..3e15e3308a 100644
--- a/lambdas/functions/webhook/src/ConfigLoader.test.ts
+++ b/lambdas/functions/webhook/src/ConfigLoader.test.ts
@@ -168,6 +168,54 @@ describe('ConfigLoader Tests', () => {
'Failed to load config: Failed to load parameter for matcherConfig from path /path/to/matcher/config: Failed to load matcher config', // eslint-disable-line max-len
);
});
+
+ it('should load config successfully from multiple paths', async () => {
+ process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config-1:/path/to/matcher/config-2';
+ process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret';
+
+ const partialMatcher1 =
+ '[{"id":"1","arn":"arn:aws:sqs:queue1","matcherConfig":{"labelMatchers":[["a"]],"exactMatch":true}}';
+ const partialMatcher2 =
+ ',{"id":"2","arn":"arn:aws:sqs:queue2","matcherConfig":{"labelMatchers":[["b"]],"exactMatch":true}}]';
+
+ const combinedMatcherConfig = [
+ { id: '1', arn: 'arn:aws:sqs:queue1', matcherConfig: { labelMatchers: [['a']], exactMatch: true } },
+ { id: '2', arn: 'arn:aws:sqs:queue2', matcherConfig: { labelMatchers: [['b']], exactMatch: true } },
+ ];
+
+ vi.mocked(getParameter).mockImplementation(async (paramPath: string) => {
+ if (paramPath === '/path/to/matcher/config-1') return partialMatcher1;
+ if (paramPath === '/path/to/matcher/config-2') return partialMatcher2;
+ if (paramPath === '/path/to/webhook/secret') return 'secret';
+ return '';
+ });
+
+ const config: ConfigWebhook = await ConfigWebhook.load();
+
+ expect(config.matcherConfig).toEqual(combinedMatcherConfig);
+ expect(config.webhookSecret).toBe('secret');
+ });
+
+ it('should throw error if config loading fails from multiple paths', async () => {
+ process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config-1:/path/to/matcher/config-2';
+ process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret';
+
+ const partialMatcher1 =
+ '[{"id":"1","arn":"arn:aws:sqs:queue1","matcherConfig":{"labelMatchers":[["a"]],"exactMatch":true}}';
+ const partialMatcher2 =
+ ',{"id":"2","arn":"arn:aws:sqs:queue2","matcherConfig":{"labelMatchers":[["b"]],"exactMatch":true}}';
+
+ vi.mocked(getParameter).mockImplementation(async (paramPath: string) => {
+ if (paramPath === '/path/to/matcher/config-1') return partialMatcher1;
+ if (paramPath === '/path/to/matcher/config-2') return partialMatcher2;
+ if (paramPath === '/path/to/webhook/secret') return 'secret';
+ return '';
+ });
+
+ await expect(ConfigWebhook.load()).rejects.toThrow(
+ "Failed to load config: Failed to parse combined matcher config: Expected ',' or ']' after array element in JSON at position 196", // eslint-disable-line max-len
+ );
+ });
});
describe('ConfigWebhookEventBridge', () => {
@@ -229,6 +277,32 @@ describe('ConfigLoader Tests', () => {
expect(config.matcherConfig).toEqual(matcherConfig);
});
+ it('should load config successfully from multiple paths with repo allow list', async () => {
+ process.env.REPOSITORY_ALLOW_LIST = '["repo1", "repo2"]';
+ process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config-1:/path/to/matcher/config-2';
+
+ const partial1 =
+ '[{"id":"1","arn":"arn:aws:sqs:queue1","matcherConfig":{"labelMatchers":[["x"]],"exactMatch":true}}';
+ const partial2 =
+ ',{"id":"2","arn":"arn:aws:sqs:queue2","matcherConfig":{"labelMatchers":[["y"]],"exactMatch":true}}]';
+
+ const combined: RunnerMatcherConfig[] = [
+ { id: '1', arn: 'arn:aws:sqs:queue1', matcherConfig: { labelMatchers: [['x']], exactMatch: true } },
+ { id: '2', arn: 'arn:aws:sqs:queue2', matcherConfig: { labelMatchers: [['y']], exactMatch: true } },
+ ];
+
+ vi.mocked(getParameter).mockImplementation(async (paramPath: string) => {
+ if (paramPath === '/path/to/matcher/config-1') return partial1;
+ if (paramPath === '/path/to/matcher/config-2') return partial2;
+ return '';
+ });
+
+ const config: ConfigDispatcher = await ConfigDispatcher.load();
+
+ expect(config.repositoryAllowList).toEqual(['repo1', 'repo2']);
+ expect(config.matcherConfig).toEqual(combined);
+ });
+
it('should throw error if config loading fails', async () => {
vi.mocked(getParameter).mockImplementation(async (paramPath: string) => {
throw new Error(`Parameter ${paramPath} not found`);
@@ -276,7 +350,7 @@ describe('ConfigLoader Tests', () => {
return '';
});
- await expect(ConfigDispatcher.load()).rejects.toThrow('ailed to load config: Matcher config is empty');
+ await expect(ConfigDispatcher.load()).rejects.toThrow('Failed to load config: Matcher config is empty');
});
});
});
diff --git a/lambdas/functions/webhook/src/ConfigLoader.ts b/lambdas/functions/webhook/src/ConfigLoader.ts
index 646acfaa53..4af58022a4 100644
--- a/lambdas/functions/webhook/src/ConfigLoader.ts
+++ b/lambdas/functions/webhook/src/ConfigLoader.ts
@@ -87,9 +87,36 @@ abstract class BaseConfig {
}
}
-export class ConfigWebhook extends BaseConfig {
- repositoryAllowList: string[] = [];
+abstract class MatcherAwareConfig extends BaseConfig {
matcherConfig: RunnerMatcherConfig[] = [];
+
+ protected async loadMatcherConfig(paramPathsEnv: string) {
+ if (!paramPathsEnv || paramPathsEnv === 'undefined' || paramPathsEnv === 'null' || !paramPathsEnv.includes(':')) {
+ // Single path or invalid string → load directly
+ await this.loadParameter(paramPathsEnv, 'matcherConfig');
+ return;
+ }
+
+ const paths = paramPathsEnv
+ .split(':')
+ .map((p) => p.trim())
+ .filter(Boolean);
+ let combinedString = '';
+ for (const path of paths) {
+ await this.loadParameter(path, 'matcherConfig');
+ combinedString += this.matcherConfig;
+ }
+
+ try {
+ this.matcherConfig = JSON.parse(combinedString);
+ } catch (error) {
+ this.configLoadingErrors.push(`Failed to parse combined matcher config: ${(error as Error).message}`);
+ }
+ }
+}
+
+export class ConfigWebhook extends MatcherAwareConfig {
+ repositoryAllowList: string[] = [];
webhookSecret: string = '';
workflowJobEventSecondaryQueue: string = '';
@@ -97,7 +124,7 @@ export class ConfigWebhook extends BaseConfig {
this.loadEnvVar(process.env.REPOSITORY_ALLOW_LIST, 'repositoryAllowList', []);
await Promise.all([
- this.loadParameter(process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH, 'matcherConfig'),
+ this.loadMatcherConfig(process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH),
this.loadParameter(process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET, 'webhookSecret'),
]);
@@ -121,14 +148,13 @@ export class ConfigWebhookEventBridge extends BaseConfig {
}
}
-export class ConfigDispatcher extends BaseConfig {
+export class ConfigDispatcher extends MatcherAwareConfig {
repositoryAllowList: string[] = [];
- matcherConfig: RunnerMatcherConfig[] = [];
workflowJobEventSecondaryQueue: string = ''; // Deprecated
async loadConfig(): Promiseobject({
enable = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
}) | `{}` | no |
-| [multi\_runner\_config](#input\_multi\_runner\_config) | multi\_runner\_config = {map(object({
runner_config = object({
runner_os = string
runner_architecture = string
runner_metadata_options = optional(map(any), {
instance_metadata_tags = "enabled"
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), null) # Defaults to null, in which case the module falls back to individual AMI variables (deprecated)
# Deprecated: Use ami object instead
ami_filter = optional(map(list(string)), { state = ["available"] })
ami_owners = optional(list(string), ["amazon"])
ami_id_ssm_parameter_name = optional(string, null)
ami_kms_key_arn = optional(string, "")
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
delay_webhook_event = optional(number, 30)
disable_runner_autoupdate = optional(bool, false)
ebs_optimized = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_on_demand_failover_for_errors = optional(list(string), [])
enable_organization_runners = optional(bool, false)
enable_runner_binaries_syncer = optional(bool, true)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_types = list(string)
job_queue_retention_in_seconds = optional(number, 86400)
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_additional_security_group_ids = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
enable_jit_config = optional(bool, null)
enable_runner_detailed_monitoring = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
cloudwatch_config = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
runner_ec2_tags = optional(map(string), {})
runner_iam_role_managed_policy_arns = optional(list(string), [])
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
cpu_options = optional(object({
core_count = number
threads_per_core = number
}), null)
runner_log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
})), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(object({
enable = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 30)
max_attempts = optional(number, 1)
}), {})
})
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
priority = optional(number, 999)
})
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
})) | n/a | yes |
+| [multi\_runner\_config](#input\_multi\_runner\_config) | multi\_runner\_config = {map(object({
runner_config = object({
runner_os = string
runner_architecture = string
runner_metadata_options = optional(map(any), {
instance_metadata_tags = "enabled"
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), null) # Defaults to null, in which case the module falls back to individual AMI variables (deprecated)
# Deprecated: Use ami object instead
ami_filter = optional(map(list(string)), { state = ["available"] })
ami_owners = optional(list(string), ["amazon"])
ami_id_ssm_parameter_name = optional(string, null)
ami_kms_key_arn = optional(string, "")
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
delay_webhook_event = optional(number, 30)
disable_runner_autoupdate = optional(bool, false)
ebs_optimized = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_on_demand_failover_for_errors = optional(list(string), [])
enable_organization_runners = optional(bool, false)
enable_runner_binaries_syncer = optional(bool, true)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_types = list(string)
job_queue_retention_in_seconds = optional(number, 86400)
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_additional_security_group_ids = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
enable_jit_config = optional(bool, null)
enable_runner_detailed_monitoring = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
cloudwatch_config = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
runner_ec2_tags = optional(map(string), {})
runner_iam_role_managed_policy_arns = optional(list(string), [])
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
cpu_options = optional(object({
core_count = number
threads_per_core = number
}), null)
runner_log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
})), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(object({
enable = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 30)
max_attempts = optional(number, 1)
}), {})
})
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
priority = optional(number, 999)
})
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
})) | n/a | yes |
| [pool\_lambda\_reserved\_concurrent\_executions](#input\_pool\_lambda\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no |
| [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Time out for the pool lambda in seconds. | `number` | `60` | no |
| [prefix](#input\_prefix) | The prefix used for naming resources | `string` | `"github-actions"` | no |
diff --git a/modules/webhook/direct/README.md b/modules/webhook/direct/README.md
index fa8848b8ec..ee3db410b9 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)
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), [])
kms_key_arn = optional(string, null)
log_level = optional(string, "info")
lambda_runtime = optional(string, "nodejs22.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 = 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)
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), [])
kms_key_arn = optional(string, null)
log_level = optional(string, "info")
lambda_runtime = optional(string, "nodejs22.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 |
## Outputs
diff --git a/modules/webhook/direct/variables.tf b/modules/webhook/direct/variables.tf
index 4acf670f38..2a1b559c92 100644
--- a/modules/webhook/direct/variables.tf
+++ b/modules/webhook/direct/variables.tf
@@ -41,10 +41,10 @@ variable "config" {
}), {})
lambda_tags = optional(map(string), {})
api_gw_source_arn = string
- ssm_parameter_runner_matcher_config = object({
+ ssm_parameter_runner_matcher_config = list(object({
name = string
arn = string
version = string
- })
+ }))
})
}
diff --git a/modules/webhook/direct/webhook.tf b/modules/webhook/direct/webhook.tf
index 362ed3e044..fda61dfa91 100644
--- a/modules/webhook/direct/webhook.tf
+++ b/modules/webhook/direct/webhook.tf
@@ -26,8 +26,8 @@ resource "aws_lambda_function" "webhook" {
POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.tracing_config.capture_error
PARAMETER_GITHUB_APP_WEBHOOK_SECRET = var.config.github_app_parameters.webhook_secret.name
REPOSITORY_ALLOW_LIST = jsonencode(var.config.repository_white_list)
- PARAMETER_RUNNER_MATCHER_CONFIG_PATH = var.config.ssm_parameter_runner_matcher_config.name
- PARAMETER_RUNNER_MATCHER_VERSION = var.config.ssm_parameter_runner_matcher_config.version # enforce cold start after Changes in SSM parameter
+ PARAMETER_RUNNER_MATCHER_CONFIG_PATH = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name])
+ PARAMETER_RUNNER_MATCHER_VERSION = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.version]) # enforce cold start after Changes in SSM parameter
} : k => v if v != null
}
}
@@ -134,7 +134,12 @@ resource "aws_iam_role_policy" "webhook_ssm" {
role = aws_iam_role.webhook_lambda.name
policy = templatefile("${path.module}/../policies/lambda-ssm.json", {
- resource_arns = jsonencode([var.config.github_app_parameters.webhook_secret.arn, var.config.ssm_parameter_runner_matcher_config.arn])
+ resource_arns = jsonencode(
+ concat(
+ [var.config.github_app_parameters.webhook_secret.arn],
+ [for p in var.config.ssm_parameter_runner_matcher_config : p.arn]
+ )
+ )
})
}
diff --git a/modules/webhook/eventbridge/README.md b/modules/webhook/eventbridge/README.md
index b02fe72f60..329ac3c232 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)
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), [])
kms_key_arn = optional(string, null)
log_level = optional(string, "info")
lambda_runtime = optional(string, "nodejs22.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 = 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)
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), [])
kms_key_arn = optional(string, null)
log_level = optional(string, "info")
lambda_runtime = optional(string, "nodejs22.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 |
## Outputs
diff --git a/modules/webhook/eventbridge/dispatcher.tf b/modules/webhook/eventbridge/dispatcher.tf
index 2e311c533f..9f06e322da 100644
--- a/modules/webhook/eventbridge/dispatcher.tf
+++ b/modules/webhook/eventbridge/dispatcher.tf
@@ -44,8 +44,8 @@ resource "aws_lambda_function" "dispatcher" {
POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.config.tracing_config.capture_http_requests
POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.tracing_config.capture_error
# Parameters required for lambda configuration
- PARAMETER_RUNNER_MATCHER_CONFIG_PATH = var.config.ssm_parameter_runner_matcher_config.name
- PARAMETER_RUNNER_MATCHER_VERSION = var.config.ssm_parameter_runner_matcher_config.version # enforce cold start after Changes in SSM parameter
+ PARAMETER_RUNNER_MATCHER_CONFIG_PATH = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name])
+ PARAMETER_RUNNER_MATCHER_VERSION = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.version]) # enforce cold start after Changes in SSM parameter
REPOSITORY_ALLOW_LIST = jsonencode(var.config.repository_white_list)
} : k => v if v != null
}
@@ -129,7 +129,11 @@ resource "aws_iam_role_policy" "dispatcher_ssm" {
role = aws_iam_role.dispatcher_lambda.name
policy = templatefile("${path.module}/../policies/lambda-ssm.json", {
- resource_arns = jsonencode([var.config.ssm_parameter_runner_matcher_config.arn])
+ resource_arns = jsonencode(
+ concat(
+ [for p in var.config.ssm_parameter_runner_matcher_config : p.arn]
+ )
+ )
})
}
diff --git a/modules/webhook/eventbridge/variables.tf b/modules/webhook/eventbridge/variables.tf
index 18eaeb78dd..8a884a6ba3 100644
--- a/modules/webhook/eventbridge/variables.tf
+++ b/modules/webhook/eventbridge/variables.tf
@@ -41,11 +41,11 @@ variable "config" {
}), {})
lambda_tags = optional(map(string), {})
api_gw_source_arn = string
- ssm_parameter_runner_matcher_config = object({
+ ssm_parameter_runner_matcher_config = list(object({
name = string
arn = string
version = string
- })
+ }))
accept_events = optional(list(string), null)
})
}
diff --git a/modules/webhook/eventbridge/webhook.tf b/modules/webhook/eventbridge/webhook.tf
index c57b6da5e3..8792403134 100644
--- a/modules/webhook/eventbridge/webhook.tf
+++ b/modules/webhook/eventbridge/webhook.tf
@@ -31,7 +31,7 @@ resource "aws_lambda_function" "webhook" {
ACCEPT_EVENTS = jsonencode(var.config.accept_events)
EVENT_BUS_NAME = aws_cloudwatch_event_bus.main.name
PARAMETER_GITHUB_APP_WEBHOOK_SECRET = var.config.github_app_parameters.webhook_secret.name
- PARAMETER_RUNNER_MATCHER_CONFIG_PATH = var.config.ssm_parameter_runner_matcher_config.name
+ PARAMETER_RUNNER_MATCHER_CONFIG_PATH = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name])
} : k => v if v != null
}
}
diff --git a/modules/webhook/webhook.tf b/modules/webhook/webhook.tf
index 49ea5494f5..f61d3adc5f 100644
--- a/modules/webhook/webhook.tf
+++ b/modules/webhook/webhook.tf
@@ -4,12 +4,45 @@ locals {
# sorted list
runner_matcher_config_sorted = [for k in sort(keys(local.runner_matcher_config)) : local.runner_matcher_config[k]]
+
+ # Calculate worst-case scenario values to determine optimal parameter chunking
+ # These intermediate values help estimate the maximum possible size of the matcher config JSON
+ # when serialized, allowing us to pre-calculate how to split it across multiple SSM parameters
+ # if it exceeds the size limits (4KB for Standard tier, 8KB for Advanced tier).
+
+ # Define worst-case dummy ARN/ID lengths
+ worst_case_arn = join("", [for i in range(0, 127) : "X"]) # ARN length assuming 80-char queue name, longest partition & region
+ worst_case_id = join("", [for i in range(0, 135) : "Y"]) # SQS URL length for same worst-case scenario
+
+ # Compute worst-case JSON length using maximum possible ARN/ID values
+ # This ensures we allocate enough parameter chunks even in the most extreme case
+ worst_case_json_length = length(jsonencode([for r in local.runner_matcher_config_sorted : merge(r, { arn = local.worst_case_arn, id = local.worst_case_id })]))
+
+ # Set max chunk size based on SSM tier
+ # AWS SSM limits:
+ # - Standard: 4096 bytes
+ # - Advanced: 8192 bytes
+ # We leave a small safety margin to avoid hitting the exact limit
+ # (e.g., escaped characters or minor overhead could exceed the limit)
+ max_chunk_size = var.matcher_config_parameter_store_tier == "Advanced" ? 8000 : 4000
+
+ # Calculate total number of chunks
+ total_chunks = ceil(local.worst_case_json_length / local.max_chunk_size)
+
+ # Encode the sorted matcher config as JSON
+ matcher_json = jsonencode(local.runner_matcher_config_sorted)
+ chunk_size = ceil(length(local.matcher_json) / local.total_chunks)
+
+ # 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)]
}
resource "aws_ssm_parameter" "runner_matcher_config" {
- name = "${var.ssm_paths.root}/${var.ssm_paths.webhook}/runner-matcher-config"
+ count = local.total_chunks
+
+ name = "${var.ssm_paths.root}/${var.ssm_paths.webhook}/runner-matcher-config${local.total_chunks > 1 ? "-${count.index}" : ""}"
type = "String"
- value = jsonencode(local.runner_matcher_config_sorted)
+ value = local.matcher_json_chunks[count.index]
tier = var.matcher_config_parameter_store_tier
tags = var.tags
}
@@ -47,7 +80,13 @@ module "direct" {
lambda_tags = var.lambda_tags,
matcher_config_parameter_store_tier = var.matcher_config_parameter_store_tier,
api_gw_source_arn = "${aws_apigatewayv2_api.webhook.execution_arn}/*/*/${local.webhook_endpoint}"
- ssm_parameter_runner_matcher_config = aws_ssm_parameter.runner_matcher_config
+ ssm_parameter_runner_matcher_config = [
+ for p in aws_ssm_parameter.runner_matcher_config : {
+ name = p.name
+ arn = p.arn
+ version = p.version
+ }
+ ]
}
}
@@ -82,8 +121,14 @@ module "eventbridge" {
tracing_config = var.tracing_config,
lambda_tags = var.lambda_tags,
api_gw_source_arn = "${aws_apigatewayv2_api.webhook.execution_arn}/*/*/${local.webhook_endpoint}"
- ssm_parameter_runner_matcher_config = aws_ssm_parameter.runner_matcher_config
- accept_events = var.eventbridge.accept_events
+ ssm_parameter_runner_matcher_config = [
+ for p in aws_ssm_parameter.runner_matcher_config : {
+ name = p.name
+ arn = p.arn
+ version = p.version
+ }
+ ]
+ accept_events = var.eventbridge.accept_events
}
}