Skip to content

Commit 6e055f8

Browse files
Brend-Smitsgithub-actions[bot]
authored andcommitted
feat: add bidirectionalLabelMatch option and deprecate exactMatch
Upstream-PR: github-aws-runners#5031 Signed-off-by: Brend Smits <brend.smits@philips.com>
1 parent 7cf4a3e commit 6e055f8

11 files changed

Lines changed: 125 additions & 20 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,11 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh)
127127
| <a name="input_enable_job_queued_check"></a> [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 |
128128
| <a name="input_enable_managed_runner_security_group"></a> [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 |
129129
| <a name="input_enable_organization_runners"></a> [enable\_organization\_runners](#input\_enable\_organization\_runners) | Register runners to organization, instead of repo level | `bool` | `false` | no |
130+
| <a name="input_enable_runner_bidirectional_label_match"></a> [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 |
130131
| <a name="input_enable_runner_binaries_syncer"></a> [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 |
131132
| <a name="input_enable_runner_detailed_monitoring"></a> [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 |
132133
| <a name="input_enable_runner_on_demand_failover_for_errors"></a> [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 |
133-
| <a name="input_enable_runner_workflow_job_labels_check_all"></a> [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 |
134+
| <a name="input_enable_runner_workflow_job_labels_check_all"></a> [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 |
134135
| <a name="input_enable_ssm_on_runners"></a> [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 |
135136
| <a name="input_enable_user_data_debug_logging_runner"></a> [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 |
136137
| <a name="input_enable_userdata"></a> [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 |

lambdas/functions/webhook/src/ConfigLoader.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ describe('ConfigLoader Tests', () => {
165165
});
166166

167167
await expect(ConfigWebhook.load()).rejects.toThrow(
168-
'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
168+
'Failed to load config: Failed to load parameter for matcherConfig from path /path/to/matcher/config: Failed to load matcher config',
169169
);
170170
});
171171

@@ -256,7 +256,7 @@ describe('ConfigLoader Tests', () => {
256256
});
257257

258258
await expect(ConfigWebhookEventBridge.load()).rejects.toThrow(
259-
'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
259+
'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',
260260
);
261261
});
262262
});
@@ -323,7 +323,7 @@ describe('ConfigLoader Tests', () => {
323323
});
324324

325325
await expect(ConfigDispatcher.load()).rejects.toThrow(
326-
'Failed to load config: Failed to load parameter for matcherConfig from path undefined: Parameter undefined not found', // eslint-disable-line max-len
326+
'Failed to load config: Failed to load parameter for matcherConfig from path undefined: Parameter undefined not found',
327327
);
328328
});
329329

lambdas/functions/webhook/src/ConfigLoader.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ abstract class BaseConfig {
6161
this.loadProperty(propertyName, value);
6262
})
6363
.catch((error) => {
64-
const errorMessage = `Failed to load parameter for ${String(propertyName)} from path ${paramPath}: ${(error as Error).message}`; // eslint-disable-line max-len
64+
const errorMessage = `Failed to load parameter for ${String(propertyName)} from path ${paramPath}: ${(error as Error).message}`;
6565
this.configLoadingErrors.push(errorMessage);
6666
});
6767
}

lambdas/functions/webhook/src/runners/dispatch.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,86 @@ describe('Dispatcher', () => {
288288
const runnerLabels = [['self-hosted', 'linux', 'ghr-valid:value']];
289289
expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(true);
290290
});
291+
292+
it('should match when runner has more labels than workflow requests with exactMatch=true (unidirectional).', () => {
293+
const workflowLabels = ['self-hosted', 'linux', 'x64', 'staging', 'ubuntu-2404'];
294+
const runnerLabels = [['self-hosted', 'linux', 'x64', 'staging', 'ubuntu-2404', 'on-demand']];
295+
expect(canRunJob(workflowLabels, runnerLabels, true, false)).toBe(true);
296+
});
297+
298+
it('should match when labels are exactly identical with exactMatch=true.', () => {
299+
const workflowLabels = ['self-hosted', 'linux', 'on-demand'];
300+
const runnerLabels = [['self-hosted', 'linux', 'on-demand']];
301+
expect(canRunJob(workflowLabels, runnerLabels, true, false)).toBe(true);
302+
});
303+
304+
it('should match with exactMatch=true when labels are in different order.', () => {
305+
const workflowLabels = ['linux', 'self-hosted', 'x64'];
306+
const runnerLabels = [['self-hosted', 'linux', 'x64']];
307+
expect(canRunJob(workflowLabels, runnerLabels, true, false)).toBe(true);
308+
});
309+
310+
it('should match with exactMatch=true when labels are completely shuffled.', () => {
311+
const workflowLabels = ['x64', 'ubuntu-latest', 'self-hosted', 'linux'];
312+
const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']];
313+
expect(canRunJob(workflowLabels, runnerLabels, true, false)).toBe(true);
314+
});
315+
316+
it('should match with exactMatch=false when labels are in different order.', () => {
317+
const workflowLabels = ['gpu', 'self-hosted'];
318+
const runnerLabels = [['self-hosted', 'gpu']];
319+
expect(canRunJob(workflowLabels, runnerLabels, false, false)).toBe(true);
320+
});
321+
322+
// bidirectionalLabelMatch tests
323+
it('should NOT match when runner has more labels than workflow requests (bidirectionalLabelMatch=true).', () => {
324+
const workflowLabels = ['self-hosted', 'linux', 'x64', 'staging', 'ubuntu-2404'];
325+
const runnerLabels = [['self-hosted', 'linux', 'x64', 'staging', 'ubuntu-2404', 'on-demand']];
326+
expect(canRunJob(workflowLabels, runnerLabels, false, false, true)).toBe(false);
327+
});
328+
329+
it('should NOT match when workflow has more labels than runner (bidirectionalLabelMatch=true).', () => {
330+
const workflowLabels = ['self-hosted', 'linux', 'x64', 'ubuntu-latest', 'gpu'];
331+
const runnerLabels = [['self-hosted', 'linux', 'x64']];
332+
expect(canRunJob(workflowLabels, runnerLabels, false, false, true)).toBe(false);
333+
});
334+
335+
it('should match when labels are exactly identical with bidirectionalLabelMatch=true.', () => {
336+
const workflowLabels = ['self-hosted', 'linux', 'on-demand'];
337+
const runnerLabels = [['self-hosted', 'linux', 'on-demand']];
338+
expect(canRunJob(workflowLabels, runnerLabels, false, false, true)).toBe(true);
339+
});
340+
341+
it('should match with bidirectionalLabelMatch=true when labels are in different order.', () => {
342+
const workflowLabels = ['linux', 'self-hosted', 'x64'];
343+
const runnerLabels = [['self-hosted', 'linux', 'x64']];
344+
expect(canRunJob(workflowLabels, runnerLabels, false, false, true)).toBe(true);
345+
});
346+
347+
it('should match with bidirectionalLabelMatch=true when labels are completely shuffled.', () => {
348+
const workflowLabels = ['x64', 'ubuntu-latest', 'self-hosted', 'linux'];
349+
const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']];
350+
expect(canRunJob(workflowLabels, runnerLabels, false, false, true)).toBe(true);
351+
});
352+
353+
it('should match with bidirectionalLabelMatch=true ignoring case.', () => {
354+
const workflowLabels = ['Self-Hosted', 'Linux', 'X64'];
355+
const runnerLabels = [['self-hosted', 'linux', 'x64']];
356+
expect(canRunJob(workflowLabels, runnerLabels, false, false, true)).toBe(true);
357+
});
358+
359+
it('should NOT match empty workflow labels with bidirectionalLabelMatch=true.', () => {
360+
const workflowLabels: string[] = [];
361+
const runnerLabels = [['self-hosted', 'linux', 'x64']];
362+
expect(canRunJob(workflowLabels, runnerLabels, false, false, true)).toBe(false);
363+
});
364+
365+
it('bidirectionalLabelMatch takes precedence over exactMatch when both are true.', () => {
366+
const workflowLabels = ['self-hosted', 'linux', 'x64'];
367+
const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']];
368+
// exactMatch alone would accept this (runner has extra labels), but bidirectional should reject
369+
expect(canRunJob(workflowLabels, runnerLabels, true, false, true)).toBe(false);
370+
});
291371
});
292372
});
293373

lambdas/functions/webhook/src/runners/dispatch.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,11 @@ async function handleWorkflowJob(
4343
`Job ID: ${body.workflow_job.id}, Job Name: ${body.workflow_job.name}, ` +
4444
`Run ID: ${body.workflow_job.run_id}, Labels: ${JSON.stringify(body.workflow_job.labels)}`,
4545
);
46-
// sort the queuesConfig by order of matcher config exact match, with all true matches lined up ahead.
46+
// sort the queuesConfig by order of matcher config exact/bidirectional match, with all true matches lined up ahead.
4747
matcherConfig.sort((a, b) => {
48-
return a.matcherConfig.exactMatch === b.matcherConfig.exactMatch ? 0 : a.matcherConfig.exactMatch ? -1 : 1;
48+
const aStrict = a.matcherConfig.bidirectionalLabelMatch || a.matcherConfig.exactMatch;
49+
const bStrict = b.matcherConfig.bidirectionalLabelMatch || b.matcherConfig.exactMatch;
50+
return aStrict === bStrict ? 0 : aStrict ? -1 : 1;
4951
});
5052
for (const queue of matcherConfig) {
5153
if (
@@ -54,6 +56,7 @@ async function handleWorkflowJob(
5456
queue.matcherConfig.labelMatchers,
5557
queue.matcherConfig.exactMatch,
5658
enableDynamicLabels,
59+
queue.matcherConfig.bidirectionalLabelMatch,
5760
)
5861
) {
5962
await sendActionRequest({
@@ -111,17 +114,27 @@ export function canRunJob(
111114
runnerLabelsMatchers: string[][],
112115
workflowLabelCheckAll: boolean,
113116
enableDynamicLabels: boolean,
117+
bidirectionalLabelMatch = false,
114118
): boolean {
115119
// Filter out ghr- labels only and sanitize them if dynamic labels is enabled, otherwise keep all labels as is for matching.
116120
const sanitizedLabels = enableDynamicLabels ? sanitizeGhrLabels(workflowJobLabels) : workflowJobLabels;
117121

118122
runnerLabelsMatchers = runnerLabelsMatchers.map((runnerLabel) => {
119123
return runnerLabel.map((label) => label.toLowerCase());
120124
});
121-
const matchLabels = workflowLabelCheckAll
122-
? runnerLabelsMatchers.some((rl) => sanitizedLabels.every((wl) => rl.includes(wl.toLowerCase())))
123-
: runnerLabelsMatchers.some((rl) => sanitizedLabels.some((wl) => rl.includes(wl.toLowerCase())));
124-
const match = sanitizedLabels.length === 0 ? !matchLabels : matchLabels;
125+
126+
let match: boolean;
127+
if (bidirectionalLabelMatch) {
128+
const labelsLower = sanitizedLabels.map((wl) => wl.toLowerCase());
129+
match = runnerLabelsMatchers.some(
130+
(rl) => labelsLower.every((wl) => rl.includes(wl)) && rl.every((r) => labelsLower.includes(r)),
131+
);
132+
} else {
133+
const matchLabels = workflowLabelCheckAll
134+
? runnerLabelsMatchers.some((rl) => sanitizedLabels.every((wl) => rl.includes(wl.toLowerCase())))
135+
: runnerLabelsMatchers.some((rl) => sanitizedLabels.some((wl) => rl.includes(wl.toLowerCase())));
136+
match = sanitizedLabels.length === 0 ? !matchLabels : matchLabels;
137+
}
125138

126139
logger.debug(
127140
`Received workflow job event with labels: '${JSON.stringify(workflowJobLabels)}'. The event does ${

lambdas/functions/webhook/src/sqs/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface ActionRequestMessage {
1818
export interface MatcherConfig {
1919
labelMatchers: string[][];
2020
exactMatch: boolean;
21+
bidirectionalLabelMatch?: boolean;
2122
}
2223

2324
export type RunnerConfig = RunnerMatcherConfig[];

main.tf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ module "webhook" {
114114
matcherConfig : {
115115
labelMatchers : [local.runner_labels]
116116
exactMatch : var.enable_runner_workflow_job_labels_check_all
117+
bidirectionalLabelMatch : var.enable_runner_bidirectional_label_match
117118
}
118119
}
119120
}

modules/multi-runner/variables.tf

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,10 @@ variable "multi_runner_config" {
200200
})
201201
})
202202
matcherConfig = object({
203-
labelMatchers = list(list(string))
204-
exactMatch = optional(bool, false)
205-
priority = optional(number, 999)
203+
labelMatchers = list(list(string))
204+
exactMatch = optional(bool, false)
205+
bidirectionalLabelMatch = optional(bool, false)
206+
priority = optional(number, 999)
206207
})
207208
redrive_build_queue = optional(object({
208209
enabled = bool
@@ -275,7 +276,8 @@ variable "multi_runner_config" {
275276
}
276277
matcherConfig: {
277278
labelMatchers: "The list of list of labels supported by the runner configuration. `[[self-hosted, linux, x64, example]]`"
278-
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."
279+
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."
280+
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."
279281
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."
280282
}
281283
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."

modules/webhook/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ yarn run dist
8989
| <a name="input_repository_white_list"></a> [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 |
9090
| <a name="input_role_path"></a> [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 |
9191
| <a name="input_role_permissions_boundary"></a> [role\_permissions\_boundary](#input\_role\_permissions\_boundary) | Permissions boundary that will be added to the created role for the lambda. | `string` | `null` | no |
92-
| <a name="input_runner_matcher_config"></a> [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. | <pre>map(object({<br/> arn = string<br/> id = string<br/> matcherConfig = object({<br/> labelMatchers = list(list(string))<br/> exactMatch = bool<br/> priority = optional(number, 999)<br/> })<br/> }))</pre> | n/a | yes |
92+
| <a name="input_runner_matcher_config"></a> [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. | <pre>map(object({<br/> arn = string<br/> id = string<br/> matcherConfig = object({<br/> labelMatchers = list(list(string))<br/> exactMatch = bool<br/> bidirectionalLabelMatch = optional(bool, false)<br/> priority = optional(number, 999)<br/> })<br/> }))</pre> | n/a | yes |
9393
| <a name="input_ssm_paths"></a> [ssm\_paths](#input\_ssm\_paths) | The root path used in SSM to store configuration and secrets. | <pre>object({<br/> root = string<br/> webhook = string<br/> })</pre> | n/a | yes |
9494
| <a name="input_tags"></a> [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 |
9595
| <a name="input_tracing_config"></a> [tracing\_config](#input\_tracing\_config) | Configuration for lambda tracing. | <pre>object({<br/> mode = optional(string, null)<br/> capture_http_requests = optional(bool, false)<br/> capture_error = optional(bool, false)<br/> })</pre> | `{}` | no |

modules/webhook/variables.tf

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@ variable "runner_matcher_config" {
2828
arn = string
2929
id = string
3030
matcherConfig = object({
31-
labelMatchers = list(list(string))
32-
exactMatch = bool
33-
priority = optional(number, 999)
31+
labelMatchers = list(list(string))
32+
exactMatch = bool
33+
bidirectionalLabelMatch = optional(bool, false)
34+
priority = optional(number, 999)
3435
})
3536
}))
3637
validation {

0 commit comments

Comments
 (0)