Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,11 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh)
| <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 |
| <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 |
| <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 |
| <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 |
| <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 |
| <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 |
| <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 |
| <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 |
| <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 |
| <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 |
| <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 |
| <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 |
Expand Down
6 changes: 3 additions & 3 deletions lambdas/functions/webhook/src/ConfigLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
});

Expand Down Expand Up @@ -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',
);
});
});
Expand Down Expand Up @@ -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',
);
});

Expand Down
2 changes: 1 addition & 1 deletion lambdas/functions/webhook/src/ConfigLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
Expand Down
80 changes: 80 additions & 0 deletions lambdas/functions/webhook/src/runners/dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,86 @@ describe('Dispatcher', () => {
const runnerLabels = [['gpu']];
expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(false);
});

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);
});
});

describe('per-matcher dynamic labels handling', () => {
Expand Down
31 changes: 24 additions & 7 deletions lambdas/functions/webhook/src/runners/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,11 @@ async function handleWorkflowJob(
`Run ID: ${body.workflow_job.run_id}, Labels: ${JSON.stringify(body.workflow_job.labels)}`,
);

// Sort queues by priority (exactMatch first), as before.
// Sort queues by priority (exact/bidirectional match first), as before.
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;
});

const allLabels = body.workflow_job.labels;
Expand All @@ -61,7 +63,12 @@ async function handleWorkflowJob(

// 1. Collect all queues whose non-dynamic labels match the job.
const matches: RunnerMatcherConfig[] = matcherConfig.filter((q) =>
canRunJob(nonGhrLabels, q.matcherConfig.labelMatchers, q.matcherConfig.exactMatch),
canRunJob(
nonGhrLabels,
q.matcherConfig.labelMatchers,
q.matcherConfig.exactMatch,
q.matcherConfig.bidirectionalLabelMatch,
),
);

if (matches.length === 0) {
Expand Down Expand Up @@ -190,12 +197,22 @@ export function canRunJob(
workflowJobLabels: string[],
runnerLabelsMatchers: string[][],
workflowLabelCheckAll: boolean,
bidirectionalLabelMatch = false,
): boolean {
const lowered = runnerLabelsMatchers.map((rl) => rl.map((l) => l.toLowerCase()));
const matchLabels = workflowLabelCheckAll
? lowered.some((rl) => workflowJobLabels.every((wl) => rl.includes(wl.toLowerCase())))
: lowered.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 = lowered.some(
(rl) => workflowLabelsLower.every((wl) => rl.includes(wl)) && rl.every((r) => workflowLabelsLower.includes(r)),
);
} else {
const matchLabels = workflowLabelCheckAll
? lowered.some((rl) => workflowJobLabels.every((wl) => rl.includes(wl.toLowerCase())))
: lowered.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 ${
Expand Down
1 change: 1 addition & 0 deletions lambdas/functions/webhook/src/sqs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface ActionRequestMessage {
export interface MatcherConfig {
labelMatchers: string[][];
exactMatch: boolean;
bidirectionalLabelMatch?: boolean;
enableDynamicLabels?: boolean;
ec2DynamicLabelsPolicy?: Ec2DynamicLabelsPolicy | null;
}
Expand Down
1 change: 1 addition & 0 deletions main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
enableDynamicLabels : var.enable_dynamic_labels
ec2DynamicLabelsPolicy : var.ec2_dynamic_labels_policy
}
Expand Down
2 changes: 1 addition & 1 deletion modules/multi-runner/README.md

Large diffs are not rendered by default.

14 changes: 8 additions & 6 deletions modules/multi-runner/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -200,11 +200,12 @@ variable "multi_runner_config" {
})
})
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
ec2DynamicLabelsPolicy = optional(any, null)
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
ec2DynamicLabelsPolicy = optional(any, null)
})
redrive_build_queue = optional(object({
enabled = bool
Expand Down Expand Up @@ -276,7 +277,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."
enableDynamicLabels: "Experimental! When true the dispatcher allows `ghr-*` dynamic labels for jobs routed to this runner. Default false."
ec2DynamicLabelsPolicy: "Optional policy for `ghr-ec2-*` labels evaluated by the dispatcher. Only effective when `enableDynamicLabels = true`. Jobs whose EC2 dynamic labels violate every matching runner's policy are rejected with a 202 (a warning is logged). Evaluation: keys in `blocked_keys` are always rejected; keys in `restricted_keys` are allowed only when their value passes the rule; unlisted keys are allowed. Schema: `{ blocked_keys = [<key>], restricted_keys = { <key> = { allowed = [globs], denied = [globs], max = number|string } } }`. Keys use the dynamic label suffix, e.g. `instance-type` for `ghr-ec2-instance-type`."
Expand Down
Loading
Loading