Skip to content

Commit 7a14043

Browse files
committed
feat: add bidirectionalLabelMatch option and deprecate exactMatch
Introduce a new bidirectionalLabelMatch option that performs strict two-way label matching (runner labels must equal workflow labels as a set). This preserves the existing exactMatch behavior (unidirectional subset check) to avoid breaking changes. - Add bidirectionalLabelMatch to MatcherConfig interface (optional) - Update canRunJob to support bidirectional matching when enabled - Deprecate exactMatch in Terraform variables with migration guidance - Add bidirectionalLabelMatch to multi-runner and webhook variable types - Add new root variable enable_runner_bidirectional_label_match - Add comprehensive test coverage for bidirectional matching
1 parent ed8feac commit 7a14043

File tree

7 files changed

+91
-20
lines changed

7 files changed

+91
-20
lines changed

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

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -190,10 +190,10 @@ describe('Dispatcher', () => {
190190
expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(true);
191191
});
192192

193-
it('should NOT accept job with exact match when runner has extra labels.', () => {
193+
it('should accept job with an exact match and runner supports requested capabilities.', () => {
194194
const workflowLabels = ['self-hosted', 'linux', 'x64'];
195195
const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']];
196-
expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(false);
196+
expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(true);
197197
});
198198

199199
it('should NOT accept job with an exact match and runner not matching requested capabilities.', () => {
@@ -226,10 +226,10 @@ describe('Dispatcher', () => {
226226
expect(canRunJob(workflowLabels, runnerLabels, false)).toBe(true);
227227
});
228228

229-
it('should NOT match when runner has more labels than workflow requests (exactMatch=true).', () => {
229+
it('should match when runner has more labels than workflow requests with exactMatch=true (unidirectional).', () => {
230230
const workflowLabels = ['self-hosted', 'linux', 'x64', 'staging', 'ubuntu-2404'];
231231
const runnerLabels = [['self-hosted', 'linux', 'x64', 'staging', 'ubuntu-2404', 'on-demand']];
232-
expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(false);
232+
expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(true);
233233
});
234234

235235
it('should match when labels are exactly identical with exactMatch=true.', () => {
@@ -255,6 +255,56 @@ describe('Dispatcher', () => {
255255
const runnerLabels = [['self-hosted', 'gpu']];
256256
expect(canRunJob(workflowLabels, runnerLabels, false)).toBe(true);
257257
});
258+
259+
// bidirectionalLabelMatch tests
260+
it('should NOT match when runner has more labels than workflow requests (bidirectionalLabelMatch=true).', () => {
261+
const workflowLabels = ['self-hosted', 'linux', 'x64', 'staging', 'ubuntu-2404'];
262+
const runnerLabels = [['self-hosted', 'linux', 'x64', 'staging', 'ubuntu-2404', 'on-demand']];
263+
expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(false);
264+
});
265+
266+
it('should NOT match when workflow has more labels than runner (bidirectionalLabelMatch=true).', () => {
267+
const workflowLabels = ['self-hosted', 'linux', 'x64', 'ubuntu-latest', 'gpu'];
268+
const runnerLabels = [['self-hosted', 'linux', 'x64']];
269+
expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(false);
270+
});
271+
272+
it('should match when labels are exactly identical with bidirectionalLabelMatch=true.', () => {
273+
const workflowLabels = ['self-hosted', 'linux', 'on-demand'];
274+
const runnerLabels = [['self-hosted', 'linux', 'on-demand']];
275+
expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(true);
276+
});
277+
278+
it('should match with bidirectionalLabelMatch=true when labels are in different order.', () => {
279+
const workflowLabels = ['linux', 'self-hosted', 'x64'];
280+
const runnerLabels = [['self-hosted', 'linux', 'x64']];
281+
expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(true);
282+
});
283+
284+
it('should match with bidirectionalLabelMatch=true when labels are completely shuffled.', () => {
285+
const workflowLabels = ['x64', 'ubuntu-latest', 'self-hosted', 'linux'];
286+
const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']];
287+
expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(true);
288+
});
289+
290+
it('should match with bidirectionalLabelMatch=true ignoring case.', () => {
291+
const workflowLabels = ['Self-Hosted', 'Linux', 'X64'];
292+
const runnerLabels = [['self-hosted', 'linux', 'x64']];
293+
expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(true);
294+
});
295+
296+
it('should NOT match empty workflow labels with bidirectionalLabelMatch=true.', () => {
297+
const workflowLabels: string[] = [];
298+
const runnerLabels = [['self-hosted', 'linux', 'x64']];
299+
expect(canRunJob(workflowLabels, runnerLabels, false, true)).toBe(false);
300+
});
301+
302+
it('bidirectionalLabelMatch takes precedence over exactMatch when both are true.', () => {
303+
const workflowLabels = ['self-hosted', 'linux', 'x64'];
304+
const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']];
305+
// exactMatch alone would accept this (runner has extra labels), but bidirectional should reject
306+
expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(false);
307+
});
258308
});
259309
});
260310

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

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,21 @@ async function handleWorkflowJob(
4242
`Job ID: ${body.workflow_job.id}, Job Name: ${body.workflow_job.name}, ` +
4343
`Run ID: ${body.workflow_job.run_id}, Labels: ${JSON.stringify(body.workflow_job.labels)}`,
4444
);
45-
// sort the queuesConfig by order of matcher config exact match, with all true matches lined up ahead.
45+
// sort the queuesConfig by order of matcher config exact/bidirectional match, with all true matches lined up ahead.
4646
matcherConfig.sort((a, b) => {
47-
return a.matcherConfig.exactMatch === b.matcherConfig.exactMatch ? 0 : a.matcherConfig.exactMatch ? -1 : 1;
47+
const aStrict = a.matcherConfig.bidirectionalLabelMatch || a.matcherConfig.exactMatch;
48+
const bStrict = b.matcherConfig.bidirectionalLabelMatch || b.matcherConfig.exactMatch;
49+
return aStrict === bStrict ? 0 : aStrict ? -1 : 1;
4850
});
4951
for (const queue of matcherConfig) {
50-
if (canRunJob(body.workflow_job.labels, queue.matcherConfig.labelMatchers, queue.matcherConfig.exactMatch)) {
52+
if (
53+
canRunJob(
54+
body.workflow_job.labels,
55+
queue.matcherConfig.labelMatchers,
56+
queue.matcherConfig.exactMatch,
57+
queue.matcherConfig.bidirectionalLabelMatch,
58+
)
59+
) {
5160
await sendActionRequest({
5261
id: body.workflow_job.id,
5362
repositoryName: body.repository.name,
@@ -80,21 +89,22 @@ export function canRunJob(
8089
workflowJobLabels: string[],
8190
runnerLabelsMatchers: string[][],
8291
workflowLabelCheckAll: boolean,
92+
bidirectionalLabelMatch = false,
8393
): boolean {
8494
runnerLabelsMatchers = runnerLabelsMatchers.map((runnerLabel) => {
8595
return runnerLabel.map((label) => label.toLowerCase());
8696
});
87-
const workflowLabelsLower = workflowJobLabels.map((wl) => wl.toLowerCase());
8897

8998
let match: boolean;
90-
if (workflowLabelCheckAll) {
99+
if (bidirectionalLabelMatch) {
100+
const workflowLabelsLower = workflowJobLabels.map((wl) => wl.toLowerCase());
91101
match = runnerLabelsMatchers.some(
92102
(rl) => workflowLabelsLower.every((wl) => rl.includes(wl)) && rl.every((r) => workflowLabelsLower.includes(r)),
93103
);
94104
} else {
95-
const matchLabels = runnerLabelsMatchers.some((rl) =>
96-
workflowJobLabels.some((wl) => rl.includes(wl.toLowerCase())),
97-
);
105+
const matchLabels = workflowLabelCheckAll
106+
? runnerLabelsMatchers.some((rl) => workflowJobLabels.every((wl) => rl.includes(wl.toLowerCase())))
107+
: runnerLabelsMatchers.some((rl) => workflowJobLabels.some((wl) => rl.includes(wl.toLowerCase())));
98108
match = workflowJobLabels.length === 0 ? !matchLabels : matchLabels;
99109
}
100110

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

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

2223
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
@@ -181,9 +181,10 @@ variable "multi_runner_config" {
181181
}), {})
182182
})
183183
matcherConfig = object({
184-
labelMatchers = list(list(string))
185-
exactMatch = optional(bool, false)
186-
priority = optional(number, 999)
184+
labelMatchers = list(list(string))
185+
exactMatch = optional(bool, false)
186+
bidirectionalLabelMatch = optional(bool, false)
187+
priority = optional(number, 999)
187188
})
188189
redrive_build_queue = optional(object({
189190
enabled = bool
@@ -252,7 +253,8 @@ variable "multi_runner_config" {
252253
}
253254
matcherConfig: {
254255
labelMatchers: "The list of list of labels supported by the runner configuration. `[[self-hosted, linux, x64, example]]`"
255-
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."
256+
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."
257+
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."
256258
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."
257259
}
258260
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/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 {

variables.tf

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -624,11 +624,17 @@ variable "log_level" {
624624
}
625625

626626
variable "enable_runner_workflow_job_labels_check_all" {
627-
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."
627+
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."
628628
type = bool
629629
default = true
630630
}
631631

632+
variable "enable_runner_bidirectional_label_match" {
633+
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."
634+
type = bool
635+
default = false
636+
}
637+
632638
variable "matcher_config_parameter_store_tier" {
633639
description = "The tier of the parameter store for the matcher configuration. Valid values are `Standard`, and `Advanced`."
634640
type = string

0 commit comments

Comments
 (0)