diff --git a/README.md b/README.md index 351a160a62..9368db2dc1 100644 --- a/README.md +++ b/README.md @@ -126,10 +126,11 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [enable\_job\_queued\_check](#input\_enable\_job\_queued\_check) | Only scale if the job event received by the scale up lambda is in the queued state. By default enabled for non ephemeral runners and disabled for ephemeral. Set this variable to overwrite the default behavior. | `bool` | `null` | no | | [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Enables creation of the default managed security group. Unmanaged security groups can be specified via `runner_additional_security_group_ids`. | `bool` | `true` | no | | [enable\_organization\_runners](#input\_enable\_organization\_runners) | Register runners to organization, instead of repo level | `bool` | `false` | no | +| [enable\_runner\_bidirectional\_label\_match](#input\_enable\_runner\_bidirectional\_label\_match) | If set to true, the runner labels and workflow job labels must be an exact two-way match (same set, any order, no extras or missing labels). This is stricter than `enable_runner_workflow_job_labels_check_all` which only checks that workflow labels are a subset of runner labels. When false, if __any__ label matches it will trigger the webhook. | `bool` | `false` | no | | [enable\_runner\_binaries\_syncer](#input\_enable\_runner\_binaries\_syncer) | Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI. | `bool` | `true` | no | | [enable\_runner\_detailed\_monitoring](#input\_enable\_runner\_detailed\_monitoring) | Should detailed monitoring be enabled for the runner. Set this to true if you want to use detailed monitoring. See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html for details. | `bool` | `false` | no | | [enable\_runner\_on\_demand\_failover\_for\_errors](#input\_enable\_runner\_on\_demand\_failover\_for\_errors) | Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later. | `list(string)` | `[]` | no | -| [enable\_runner\_workflow\_job\_labels\_check\_all](#input\_enable\_runner\_workflow\_job\_labels\_check\_all) | If set to true all labels in the workflow job must match the GitHub labels (os, architecture and `self-hosted`). When false if __any__ label matches it will trigger the webhook. | `bool` | `true` | no | +| [enable\_runner\_workflow\_job\_labels\_check\_all](#input\_enable\_runner\_workflow\_job\_labels\_check\_all) | DEPRECATED: Use `enable_runner_bidirectional_label_match` instead. If set to true all labels in the workflow job must match the GitHub labels (os, architecture and `self-hosted`). When false if __any__ label matches it will trigger the webhook. Note: this only checks that workflow labels are a subset of runner labels, not the reverse. | `bool` | `true` | no | | [enable\_ssm\_on\_runners](#input\_enable\_ssm\_on\_runners) | Enable to allow access to the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances. | `bool` | `false` | no | | [enable\_user\_data\_debug\_logging\_runner](#input\_enable\_user\_data\_debug\_logging\_runner) | Option to enable debug logging for user-data, this logs all secrets as well. | `bool` | `false` | no | | [enable\_userdata](#input\_enable\_userdata) | Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI. | `bool` | `true` | no | diff --git a/lambdas/functions/webhook/src/ConfigLoader.test.ts b/lambdas/functions/webhook/src/ConfigLoader.test.ts index d1a4f304b4..fadc3954a5 100644 --- a/lambdas/functions/webhook/src/ConfigLoader.test.ts +++ b/lambdas/functions/webhook/src/ConfigLoader.test.ts @@ -165,7 +165,7 @@ describe('ConfigLoader Tests', () => { }); await expect(ConfigWebhook.load()).rejects.toThrow( - '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 + 'Failed to load config: Failed to load parameter for matcherConfig from path /path/to/matcher/config: Failed to load matcher config', ); }); @@ -256,7 +256,7 @@ describe('ConfigLoader Tests', () => { }); await expect(ConfigWebhookEventBridge.load()).rejects.toThrow( - 'Failed to load config: Environment variable for eventBusName is not set and no default value provided., Failed to load parameter for webhookSecret from path undefined: Parameter undefined not found', // eslint-disable-line max-len + 'Failed to load config: Environment variable for eventBusName is not set and no default value provided., Failed to load parameter for webhookSecret from path undefined: Parameter undefined not found', ); }); }); @@ -323,7 +323,7 @@ describe('ConfigLoader Tests', () => { }); await expect(ConfigDispatcher.load()).rejects.toThrow( - 'Failed to load config: Failed to load parameter for matcherConfig from path undefined: Parameter undefined not found', // eslint-disable-line max-len + 'Failed to load config: Failed to load parameter for matcherConfig from path undefined: Parameter undefined not found', ); }); diff --git a/lambdas/functions/webhook/src/ConfigLoader.ts b/lambdas/functions/webhook/src/ConfigLoader.ts index e77a92b16e..df7b933779 100644 --- a/lambdas/functions/webhook/src/ConfigLoader.ts +++ b/lambdas/functions/webhook/src/ConfigLoader.ts @@ -61,7 +61,7 @@ abstract class BaseConfig { this.loadProperty(propertyName, value); }) .catch((error) => { - const errorMessage = `Failed to load parameter for ${String(propertyName)} from path ${paramPath}: ${(error as Error).message}`; // eslint-disable-line max-len + const errorMessage = `Failed to load parameter for ${String(propertyName)} from path ${paramPath}: ${(error as Error).message}`; this.configLoadingErrors.push(errorMessage); }); } diff --git a/lambdas/functions/webhook/src/runners/dispatch.test.ts b/lambdas/functions/webhook/src/runners/dispatch.test.ts index e8eff9be4c..d3f1b29523 100644 --- a/lambdas/functions/webhook/src/runners/dispatch.test.ts +++ b/lambdas/functions/webhook/src/runners/dispatch.test.ts @@ -225,6 +225,86 @@ describe('Dispatcher', () => { const runnerLabels = [['self-hosted', 'linux', 'x64']]; expect(canRunJob(workflowLabels, runnerLabels, false)).toBe(true); }); + + it('should match when runner has more labels than workflow requests with exactMatch=true (unidirectional).', () => { + const workflowLabels = ['self-hosted', 'linux', 'x64', 'staging', 'ubuntu-2404']; + const runnerLabels = [['self-hosted', 'linux', 'x64', 'staging', 'ubuntu-2404', 'on-demand']]; + expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(true); + }); + + it('should match when labels are exactly identical with exactMatch=true.', () => { + const workflowLabels = ['self-hosted', 'linux', 'on-demand']; + const runnerLabels = [['self-hosted', 'linux', 'on-demand']]; + expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(true); + }); + + it('should match with exactMatch=true when labels are in different order.', () => { + const workflowLabels = ['linux', 'self-hosted', 'x64']; + const runnerLabels = [['self-hosted', 'linux', 'x64']]; + expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(true); + }); + + it('should match with exactMatch=true when labels are completely shuffled.', () => { + const workflowLabels = ['x64', 'ubuntu-latest', 'self-hosted', 'linux']; + const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']]; + expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(true); + }); + + it('should match with exactMatch=false when labels are in different order.', () => { + const workflowLabels = ['gpu', 'self-hosted']; + const runnerLabels = [['self-hosted', 'gpu']]; + expect(canRunJob(workflowLabels, runnerLabels, false)).toBe(true); + }); + + // bidirectionalLabelMatch tests + it('should NOT match when runner has more labels than workflow requests (bidirectionalLabelMatch=true).', () => { + const workflowLabels = ['self-hosted', 'linux', 'x64', 'staging', 'ubuntu-2404']; + const runnerLabels = [['self-hosted', 'linux', 'x64', 'staging', 'ubuntu-2404', 'on-demand']]; + expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(false); + }); + + it('should NOT match when workflow has more labels than runner (bidirectionalLabelMatch=true).', () => { + const workflowLabels = ['self-hosted', 'linux', 'x64', 'ubuntu-latest', 'gpu']; + const runnerLabels = [['self-hosted', 'linux', 'x64']]; + expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(false); + }); + + it('should match when labels are exactly identical with bidirectionalLabelMatch=true.', () => { + const workflowLabels = ['self-hosted', 'linux', 'on-demand']; + const runnerLabels = [['self-hosted', 'linux', 'on-demand']]; + expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(true); + }); + + it('should match with bidirectionalLabelMatch=true when labels are in different order.', () => { + const workflowLabels = ['linux', 'self-hosted', 'x64']; + const runnerLabels = [['self-hosted', 'linux', 'x64']]; + expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(true); + }); + + it('should match with bidirectionalLabelMatch=true when labels are completely shuffled.', () => { + const workflowLabels = ['x64', 'ubuntu-latest', 'self-hosted', 'linux']; + const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']]; + expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(true); + }); + + it('should match with bidirectionalLabelMatch=true ignoring case.', () => { + const workflowLabels = ['Self-Hosted', 'Linux', 'X64']; + const runnerLabels = [['self-hosted', 'linux', 'x64']]; + expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(true); + }); + + it('should NOT match empty workflow labels with bidirectionalLabelMatch=true.', () => { + const workflowLabels: string[] = []; + const runnerLabels = [['self-hosted', 'linux', 'x64']]; + expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(false); + }); + + it('bidirectionalLabelMatch takes precedence over exactMatch when both are true.', () => { + const workflowLabels = ['self-hosted', 'linux', 'x64']; + const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']]; + // exactMatch alone would accept this (runner has extra labels), but bidirectional should reject + expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(false); + }); }); }); diff --git a/lambdas/functions/webhook/src/runners/dispatch.ts b/lambdas/functions/webhook/src/runners/dispatch.ts index fe81e63a26..d0d9e992ff 100644 --- a/lambdas/functions/webhook/src/runners/dispatch.ts +++ b/lambdas/functions/webhook/src/runners/dispatch.ts @@ -42,12 +42,21 @@ async function handleWorkflowJob( `Job ID: ${body.workflow_job.id}, Job Name: ${body.workflow_job.name}, ` + `Run ID: ${body.workflow_job.run_id}, Labels: ${JSON.stringify(body.workflow_job.labels)}`, ); - // sort the queuesConfig by order of matcher config exact match, with all true matches lined up ahead. + // sort the queuesConfig by order of matcher config exact/bidirectional match, with all true matches lined up ahead. matcherConfig.sort((a, b) => { - return a.matcherConfig.exactMatch === b.matcherConfig.exactMatch ? 0 : a.matcherConfig.exactMatch ? -1 : 1; + const aStrict = a.matcherConfig.bidirectionalLabelMatch || a.matcherConfig.exactMatch; + const bStrict = b.matcherConfig.bidirectionalLabelMatch || b.matcherConfig.exactMatch; + return aStrict === bStrict ? 0 : aStrict ? -1 : 1; }); for (const queue of matcherConfig) { - if (canRunJob(body.workflow_job.labels, queue.matcherConfig.labelMatchers, queue.matcherConfig.exactMatch)) { + if ( + canRunJob( + body.workflow_job.labels, + queue.matcherConfig.labelMatchers, + queue.matcherConfig.exactMatch, + queue.matcherConfig.bidirectionalLabelMatch, + ) + ) { await sendActionRequest({ id: body.workflow_job.id, repositoryName: body.repository.name, @@ -80,14 +89,24 @@ export function canRunJob( workflowJobLabels: string[], runnerLabelsMatchers: string[][], workflowLabelCheckAll: boolean, + bidirectionalLabelMatch = false, ): boolean { runnerLabelsMatchers = runnerLabelsMatchers.map((runnerLabel) => { return runnerLabel.map((label) => label.toLowerCase()); }); - const matchLabels = workflowLabelCheckAll - ? runnerLabelsMatchers.some((rl) => workflowJobLabels.every((wl) => rl.includes(wl.toLowerCase()))) - : runnerLabelsMatchers.some((rl) => workflowJobLabels.some((wl) => rl.includes(wl.toLowerCase()))); - const match = workflowJobLabels.length === 0 ? !matchLabels : matchLabels; + + let match: boolean; + if (bidirectionalLabelMatch) { + const workflowLabelsLower = workflowJobLabels.map((wl) => wl.toLowerCase()); + match = runnerLabelsMatchers.some( + (rl) => workflowLabelsLower.every((wl) => rl.includes(wl)) && rl.every((r) => workflowLabelsLower.includes(r)), + ); + } else { + const matchLabels = workflowLabelCheckAll + ? runnerLabelsMatchers.some((rl) => workflowJobLabels.every((wl) => rl.includes(wl.toLowerCase()))) + : runnerLabelsMatchers.some((rl) => workflowJobLabels.some((wl) => rl.includes(wl.toLowerCase()))); + match = workflowJobLabels.length === 0 ? !matchLabels : matchLabels; + } logger.debug( `Received workflow job event with labels: '${JSON.stringify(workflowJobLabels)}'. The event does ${ diff --git a/lambdas/functions/webhook/src/sqs/index.ts b/lambdas/functions/webhook/src/sqs/index.ts index a028d7dcc4..b2c6982471 100644 --- a/lambdas/functions/webhook/src/sqs/index.ts +++ b/lambdas/functions/webhook/src/sqs/index.ts @@ -17,6 +17,7 @@ export interface ActionRequestMessage { export interface MatcherConfig { labelMatchers: string[][]; exactMatch: boolean; + bidirectionalLabelMatch?: boolean; } export type RunnerConfig = RunnerMatcherConfig[]; diff --git a/main.tf b/main.tf index a9a79c87a3..dc7d842cc0 100644 --- a/main.tf +++ b/main.tf @@ -114,6 +114,7 @@ module "webhook" { matcherConfig : { labelMatchers : [local.runner_labels] exactMatch : var.enable_runner_workflow_job_labels_check_all + bidirectionalLabelMatch : var.enable_runner_bidirectional_label_match } } } diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 7a050cdeee..3e8bcbdd85 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -151,7 +151,7 @@ module "multi-runner" { | [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Specifies the number of days you want to retain log events for the lambda log group. Possible values are: 0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, and 3653. | `number` | `180` | no | | [matcher\_config\_parameter\_store\_tier](#input\_matcher\_config\_parameter\_store\_tier) | The tier of the parameter store for the matcher configuration. Valid values are `Standard`, and `Advanced`. | `string` | `"Standard"` | no | | [metrics](#input\_metrics) | Configuration for metrics created by the module, by default metrics are disabled to avoid additional costs. When metrics are enable all metrics are created unless explicit configured otherwise. |
object({
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)
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), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
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)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
runner_log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), 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)
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), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
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)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
runner_log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), 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)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
})
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
})) | n/a | yes |
| [parameter\_store\_tags](#input\_parameter\_store\_tags) | Map of tags that will be added to all the SSM Parameter Store parameters created by the Lambda function. | `map(string)` | `{}` | no |
| [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 |
diff --git a/modules/multi-runner/variables.tf b/modules/multi-runner/variables.tf
index 613cf8b2ce..e105c2beae 100644
--- a/modules/multi-runner/variables.tf
+++ b/modules/multi-runner/variables.tf
@@ -182,9 +182,10 @@ variable "multi_runner_config" {
}), {})
})
matcherConfig = object({
- labelMatchers = list(list(string))
- exactMatch = optional(bool, false)
- priority = optional(number, 999)
+ labelMatchers = list(list(string))
+ exactMatch = optional(bool, false)
+ bidirectionalLabelMatch = optional(bool, false)
+ priority = optional(number, 999)
})
redrive_build_queue = optional(object({
enabled = bool
@@ -253,7 +254,8 @@ variable "multi_runner_config" {
}
matcherConfig: {
labelMatchers: "The list of list of labels supported by the runner configuration. `[[self-hosted, linux, x64, example]]`"
- exactMatch: "If set to true all labels in the workflow job must match the GitHub labels (os, architecture and `self-hosted`). When false if __any__ workflow label matches it will trigger the webhook."
+ exactMatch: "DEPRECATED: Use `bidirectionalLabelMatch` instead. If set to true all labels in the workflow job must match the GitHub labels (os, architecture and `self-hosted`). When false if __any__ workflow label matches it will trigger the webhook. Note: this only checks that workflow labels are a subset of runner labels, not the reverse."
+ bidirectionalLabelMatch: "If set to true, the runner labels and workflow job labels must be an exact two-way match (same set, any order, no extras or missing labels). This is stricter than `exactMatch` which only checks that workflow labels are a subset of runner labels. When false, if __any__ workflow label matches it will trigger the webhook."
priority: "If set it defines the priority of the matcher, the matcher with the lowest priority will be evaluated first. Default is 999, allowed values 0-999."
}
redrive_build_queue: "Set options to attach (optional) a dead letter queue to the build queue, the queue between the webhook and the scale up lambda. You have the following options. 1. Disable by setting `enabled` to false. 2. Enable by setting `enabled` to `true`, `maxReceiveCount` to a number of max retries."
diff --git a/modules/webhook/README.md b/modules/webhook/README.md
index 4c496d8775..0c5f2b7bf2 100644
--- a/modules/webhook/README.md
+++ b/modules/webhook/README.md
@@ -88,7 +88,7 @@ yarn run dist
| [repository\_white\_list](#input\_repository\_white\_list) | List of github repository full names (owner/repo\_name) that will be allowed to use the github app. Leave empty for no filtering. | `list(string)` | `[]` | no |
| [role\_path](#input\_role\_path) | The path that will be added to the role; if not set, the environment name will be used. | `string` | `null` | no |
| [role\_permissions\_boundary](#input\_role\_permissions\_boundary) | Permissions boundary that will be added to the created role for the lambda. | `string` | `null` | no |
-| [runner\_matcher\_config](#input\_runner\_matcher\_config) | SQS queue to publish accepted build events based on the runner type. When exact match is disabled the webhook accepts the event if one of the workflow job labels is part of the matcher. The priority defines the order the matchers are applied. | map(object({
arn = string
id = string
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = bool
priority = optional(number, 999)
})
})) | n/a | yes |
+| [runner\_matcher\_config](#input\_runner\_matcher\_config) | SQS queue to publish accepted build events based on the runner type. When exact match is disabled the webhook accepts the event if one of the workflow job labels is part of the matcher. The priority defines the order the matchers are applied. | map(object({
arn = string
id = string
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = bool
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
})
})) | n/a | yes |
| [ssm\_paths](#input\_ssm\_paths) | The root path used in SSM to store configuration and secrets. | object({
root = string
webhook = string
}) | n/a | yes |
| [tags](#input\_tags) | Map of tags that will be added to created resources. By default resources will be tagged with name and environment. | `map(string)` | `{}` | no |
| [tracing\_config](#input\_tracing\_config) | Configuration for lambda tracing. | object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}) | `{}` | no |
diff --git a/modules/webhook/variables.tf b/modules/webhook/variables.tf
index a7b8f8173e..e5eef96f39 100644
--- a/modules/webhook/variables.tf
+++ b/modules/webhook/variables.tf
@@ -28,9 +28,10 @@ variable "runner_matcher_config" {
arn = string
id = string
matcherConfig = object({
- labelMatchers = list(list(string))
- exactMatch = bool
- priority = optional(number, 999)
+ labelMatchers = list(list(string))
+ exactMatch = bool
+ bidirectionalLabelMatch = optional(bool, false)
+ priority = optional(number, 999)
})
}))
validation {
diff --git a/variables.tf b/variables.tf
index d739e916fb..fe0859be85 100644
--- a/variables.tf
+++ b/variables.tf
@@ -636,11 +636,17 @@ variable "log_level" {
}
variable "enable_runner_workflow_job_labels_check_all" {
- description = "If set to true all labels in the workflow job must match the GitHub labels (os, architecture and `self-hosted`). When false if __any__ label matches it will trigger the webhook."
+ description = "DEPRECATED: Use `enable_runner_bidirectional_label_match` instead. If set to true all labels in the workflow job must match the GitHub labels (os, architecture and `self-hosted`). When false if __any__ label matches it will trigger the webhook. Note: this only checks that workflow labels are a subset of runner labels, not the reverse."
type = bool
default = true
}
+variable "enable_runner_bidirectional_label_match" {
+ description = "If set to true, the runner labels and workflow job labels must be an exact two-way match (same set, any order, no extras or missing labels). This is stricter than `enable_runner_workflow_job_labels_check_all` which only checks that workflow labels are a subset of runner labels. When false, if __any__ label matches it will trigger the webhook."
+ type = bool
+ default = false
+}
+
variable "matcher_config_parameter_store_tier" {
description = "The tier of the parameter store for the matcher configuration. Valid values are `Standard`, and `Advanced`."
type = string