From 6bcb0daf3254e7bb0d4a102084ba02a93d498347 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 12 Jun 2026 23:27:09 +0200 Subject: [PATCH 01/12] feat(webhook): add dynamic-labels-policy evaluator Pure module that, given a list of GitHub Actions labels and a policy, returns the ghr-ec2-* labels that violate the policy with a human-readable reason. Supports allowed_keys/denied_keys meta filters and per-key value rules (allowed/denied globs, numeric max). Keys use the same hyphenated form as the labels (e.g. instance-type). Not wired into dispatch yet. --- .../src/runners/dynamic-labels-policy.test.ts | 132 ++++++++++++++++++ .../src/runners/dynamic-labels-policy.ts | 83 +++++++++++ 2 files changed, 215 insertions(+) create mode 100644 lambdas/functions/webhook/src/runners/dynamic-labels-policy.test.ts create mode 100644 lambdas/functions/webhook/src/runners/dynamic-labels-policy.ts diff --git a/lambdas/functions/webhook/src/runners/dynamic-labels-policy.test.ts b/lambdas/functions/webhook/src/runners/dynamic-labels-policy.test.ts new file mode 100644 index 0000000000..c6172d7c8f --- /dev/null +++ b/lambdas/functions/webhook/src/runners/dynamic-labels-policy.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest'; + +import { violationsAgainstPolicy, type DynamicLabelsPolicy } from './dynamic-labels-policy'; + +describe('violationsAgainstPolicy', () => { + it('returns [] when policy is null/undefined', () => { + expect(violationsAgainstPolicy(['ghr-ec2-instance-type:m5.large'], null)).toEqual([]); + expect(violationsAgainstPolicy(['ghr-ec2-instance-type:m5.large'], undefined)).toEqual([]); + }); + + it('ignores non ghr-ec2-* labels', () => { + const policy: DynamicLabelsPolicy = { allowed_keys: ['nope'] }; + expect(violationsAgainstPolicy(['ghr-team:platform', 'self-hosted'], policy)).toEqual([]); + }); + + it('accepts any key when both lists are absent', () => { + const policy: DynamicLabelsPolicy = {}; + expect( + violationsAgainstPolicy(['ghr-ec2-instance-type:m5.large', 'ghr-ec2-image-id:ami-1'], policy), + ).toEqual([]); + }); + + it('flags keys not in allowed_keys', () => { + const policy: DynamicLabelsPolicy = { allowed_keys: ['instance-type'] }; + const v = violationsAgainstPolicy( + ['ghr-ec2-instance-type:m5.large', 'ghr-ec2-image-id:ami-1'], + policy, + ); + expect(v).toHaveLength(1); + expect(v[0].label).toBe('ghr-ec2-image-id:ami-1'); + }); + + it('denied_keys takes precedence over allowed_keys', () => { + const policy: DynamicLabelsPolicy = { + allowed_keys: ['instance-type', 'image-id'], + denied_keys: ['image-id'], + }; + const v = violationsAgainstPolicy( + ['ghr-ec2-instance-type:m5.large', 'ghr-ec2-image-id:ami-1'], + policy, + ); + expect(v).toHaveLength(1); + expect(v[0].label).toBe('ghr-ec2-image-id:ami-1'); + }); + + it('per-key allowed glob with `*`', () => { + const policy: DynamicLabelsPolicy = { 'instance-type': { allowed: ['m5.*', 'c5.*'] } }; + const v = violationsAgainstPolicy( + ['ghr-ec2-instance-type:m5.large', 'ghr-ec2-instance-type:r5.large'], + policy, + ); + expect(v).toHaveLength(1); + expect(v[0].label).toBe('ghr-ec2-instance-type:r5.large'); + }); + + it('per-key allowed glob with `?`', () => { + const policy: DynamicLabelsPolicy = { 'image-id': { allowed: ['ami-?????????'] } }; + const v = violationsAgainstPolicy( + ['ghr-ec2-image-id:ami-123456789', 'ghr-ec2-image-id:ami-12345'], + policy, + ); + expect(v).toHaveLength(1); + expect(v[0].label).toBe('ghr-ec2-image-id:ami-12345'); + }); + + it('escapes regex metacharacters in patterns', () => { + const policy: DynamicLabelsPolicy = { 'instance-type': { allowed: ['m5.large'] } }; + const v = violationsAgainstPolicy(['ghr-ec2-instance-type:m5xlarge'], policy); + expect(v).toHaveLength(1); + }); + + it('empty allowed list is treated as no constraint', () => { + const policy: DynamicLabelsPolicy = { 'instance-type': { allowed: [] } }; + expect(violationsAgainstPolicy(['ghr-ec2-instance-type:any'], policy)).toEqual([]); + }); + + it('denied glob flags matches', () => { + const policy: DynamicLabelsPolicy = { 'instance-type': { denied: ['*.metal*'] } }; + const v = violationsAgainstPolicy( + ['ghr-ec2-instance-type:m5.large', 'ghr-ec2-instance-type:m5.metal'], + policy, + ); + expect(v).toHaveLength(1); + expect(v[0].label).toBe('ghr-ec2-instance-type:m5.metal'); + }); + + it('max flags values that exceed', () => { + const policy: DynamicLabelsPolicy = { 'ebs-volume-size': { max: 200 } }; + const v = violationsAgainstPolicy( + ['ghr-ec2-ebs-volume-size:100', 'ghr-ec2-ebs-volume-size:300'], + policy, + ); + expect(v).toHaveLength(1); + expect(v[0].label).toBe('ghr-ec2-ebs-volume-size:300'); + }); + + it('max flags when value is not numeric', () => { + const policy: DynamicLabelsPolicy = { 'instance-type': { max: 100 } }; + const v = violationsAgainstPolicy(['ghr-ec2-instance-type:m5.large'], policy); + expect(v).toHaveLength(1); + }); + + it('empty rule object accepts any value', () => { + const policy: DynamicLabelsPolicy = { 'instance-type': {} }; + expect(violationsAgainstPolicy(['ghr-ec2-instance-type:any'], policy)).toEqual([]); + }); + + it('accepts a value-less label whose key passes the keys filter', () => { + const policy: DynamicLabelsPolicy = { allowed_keys: ['no-device'] }; + expect(violationsAgainstPolicy(['ghr-ec2-no-device'], policy)).toEqual([]); + }); + + it('flags a value-less label when allowed_keys excludes it', () => { + const policy: DynamicLabelsPolicy = { allowed_keys: ['instance-type'] }; + const v = violationsAgainstPolicy(['ghr-ec2-no-device'], policy); + expect(v).toHaveLength(1); + }); + + it('returns a reason per violating label', () => { + const policy: DynamicLabelsPolicy = { + allowed_keys: ['instance-type'], + 'instance-type': { allowed: ['m5.*'] }, + }; + const v = violationsAgainstPolicy( + ['ghr-ec2-instance-type:r5.large', 'ghr-ec2-image-id:ami-x', 'ghr-ec2-instance-type:m5.large'], + policy, + ); + expect(v).toHaveLength(2); + expect(v[0].label).toBe('ghr-ec2-instance-type:r5.large'); + expect(v[1].label).toBe('ghr-ec2-image-id:ami-x'); + }); +}); diff --git a/lambdas/functions/webhook/src/runners/dynamic-labels-policy.ts b/lambdas/functions/webhook/src/runners/dynamic-labels-policy.ts new file mode 100644 index 0000000000..b638027b89 --- /dev/null +++ b/lambdas/functions/webhook/src/runners/dynamic-labels-policy.ts @@ -0,0 +1,83 @@ +export interface DynamicLabelsValueRule { + allowed?: string[]; + denied?: string[]; + max?: number | string; +} + +/** + * Flat policy schema. `allowed_keys` and `denied_keys` are reserved meta-keys; + * any other entry is a per-key value rule keyed by the `` segment of a + * `ghr-ec2-:` label. Keys must use the same hyphenated form as + * the labels themselves (e.g. `instance-type`). + */ +export interface DynamicLabelsPolicy { + allowed_keys?: string[]; + denied_keys?: string[]; + [key: string]: string[] | DynamicLabelsValueRule | undefined; +} + +function globToRegExp(glob: string): RegExp { + const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.'); + return new RegExp(`^${pattern}$`); +} + +function matchesAny(value: string, patterns: string[] | undefined): boolean { + if (!patterns || patterns.length === 0) return false; + return patterns.some((p) => globToRegExp(p).test(value)); +} + +function evaluateLabel(label: string, policy: DynamicLabelsPolicy): string | null { + const stripped = label.replace(/^ghr-ec2-/, ''); + const colonIdx = stripped.indexOf(':'); + const key = colonIdx === -1 ? stripped : stripped.slice(0, colonIdx); + const value = colonIdx === -1 ? undefined : stripped.slice(colonIdx + 1); + + if (policy.denied_keys?.includes(key)) { + return `key '${key}' is in denied_keys`; + } + if (policy.allowed_keys && policy.allowed_keys.length > 0 && !policy.allowed_keys.includes(key)) { + return `key '${key}' is not in allowed_keys`; + } + + if (key === 'allowed_keys' || key === 'denied_keys') return null; + const rule = policy[key]; + if (!rule || Array.isArray(rule)) return null; + if (value === undefined) return null; + + if (rule.allowed && rule.allowed.length > 0 && !matchesAny(value, rule.allowed)) { + return `value '${value}' not in allowed list`; + } + if (rule.denied && matchesAny(value, rule.denied)) { + return `value '${value}' in denied list`; + } + if (rule.max !== undefined && rule.max !== null) { + const valueNum = Number(value); + const maxNum = Number(rule.max); + if (!Number.isFinite(valueNum) || !Number.isFinite(maxNum)) { + return `max set but value '${value}' or max '${rule.max}' is not numeric`; + } + if (valueNum > maxNum) { + return `value '${value}' exceeds max '${rule.max}'`; + } + } + return null; +} + +/** + * Inspects the labels and returns the rejection reasons for any `ghr-ec2-*` + * label that violates the policy. Non-`ghr-ec2-*` labels are ignored. + */ +export function violationsAgainstPolicy( + labels: string[], + policy: DynamicLabelsPolicy | null | undefined, +): { label: string; reason: string }[] { + if (!policy) return []; + const violations: { label: string; reason: string }[] = []; + for (const label of labels) { + if (!label.startsWith('ghr-ec2-')) continue; + const reason = evaluateLabel(label, policy); + if (reason) violations.push({ label, reason }); + } + return violations; +} From c6b8d7c677481f0acb95dfe1005f9e8a881c9c50 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 12 Jun 2026 23:27:54 +0200 Subject: [PATCH 02/12] feat(webhook): carry enableDynamicLabels and policy on MatcherConfig Extend the MatcherConfig type so each runner-matcher entry can opt in to dynamic labels and ship its own per-matcher policy. The fields are optional so existing matcher configs keep working unchanged. --- lambdas/functions/webhook/src/sqs/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lambdas/functions/webhook/src/sqs/index.ts b/lambdas/functions/webhook/src/sqs/index.ts index ecf31f1cfd..319b223d6a 100644 --- a/lambdas/functions/webhook/src/sqs/index.ts +++ b/lambdas/functions/webhook/src/sqs/index.ts @@ -2,6 +2,8 @@ import { SQS, SendMessageCommandInput } from '@aws-sdk/client-sqs'; import { WorkflowJobEvent } from '@octokit/webhooks-types'; import { createChildLogger, getTracedAWSV3Client } from '@aws-github-runner/aws-powertools-util'; +import { DynamicLabelsPolicy } from '../runners/dynamic-labels-policy'; + const logger = createChildLogger('sqs'); export interface ActionRequestMessage { @@ -18,6 +20,8 @@ export interface ActionRequestMessage { export interface MatcherConfig { labelMatchers: string[][]; exactMatch: boolean; + enableDynamicLabels?: boolean; + dynamicLabelsPolicy?: DynamicLabelsPolicy | null; } export type RunnerConfig = RunnerMatcherConfig[]; From d009196dd9ddc712f3710145912ea1a14c133d98 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 12 Jun 2026 23:28:54 +0200 Subject: [PATCH 03/12] refactor(webhook): drop ENABLE_DYNAMIC_LABELS env var The flag now travels per-matcher inside the runner-matcher-config SSM blob (see MatcherConfig.enableDynamicLabels), so the global env-var version is no longer needed in either ConfigWebhook or ConfigDispatcher. --- lambdas/functions/webhook/src/ConfigLoader.ts | 4 ---- lambdas/functions/webhook/src/modules.d.ts | 1 - 2 files changed, 5 deletions(-) diff --git a/lambdas/functions/webhook/src/ConfigLoader.ts b/lambdas/functions/webhook/src/ConfigLoader.ts index df7b159495..e77a92b16e 100644 --- a/lambdas/functions/webhook/src/ConfigLoader.ts +++ b/lambdas/functions/webhook/src/ConfigLoader.ts @@ -130,11 +130,9 @@ export class ConfigWebhook extends MatcherAwareConfig { repositoryAllowList: string[] = []; webhookSecret: string = ''; workflowJobEventSecondaryQueue: string = ''; - enableDynamicLabels: boolean = false; async loadConfig(): Promise { this.loadEnvVar(process.env.REPOSITORY_ALLOW_LIST, 'repositoryAllowList', []); - this.loadEnvVar(process.env.ENABLE_DYNAMIC_LABELS, 'enableDynamicLabels', false); await Promise.all([ this.loadMatcherConfig(process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH), @@ -164,11 +162,9 @@ export class ConfigWebhookEventBridge extends BaseConfig { export class ConfigDispatcher extends MatcherAwareConfig { repositoryAllowList: string[] = []; workflowJobEventSecondaryQueue: string = ''; // Deprecated - enableDynamicLabels: boolean = false; async loadConfig(): Promise { this.loadEnvVar(process.env.REPOSITORY_ALLOW_LIST, 'repositoryAllowList', []); - this.loadEnvVar(process.env.ENABLE_DYNAMIC_LABELS, 'enableDynamicLabels', false); await this.loadMatcherConfig(process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH); validateRunnerMatcherConfig(this); diff --git a/lambdas/functions/webhook/src/modules.d.ts b/lambdas/functions/webhook/src/modules.d.ts index 9d73cf5815..76a72660c0 100644 --- a/lambdas/functions/webhook/src/modules.d.ts +++ b/lambdas/functions/webhook/src/modules.d.ts @@ -5,7 +5,6 @@ declare namespace NodeJS { PARAMETER_GITHUB_APP_WEBHOOK_SECRET: string; PARAMETER_RUNNER_MATCHER_CONFIG_PATH: string; REPOSITORY_ALLOW_LIST: string; - ENABLE_DYNAMIC_LABELS: string; RUNNER_LABELS: string; ACCEPT_EVENTS: string; } From 4c3f2830776f53c31a0abff5f04eb0237685633d Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 12 Jun 2026 23:29:18 +0200 Subject: [PATCH 04/12] feat(webhook): reject non-compliant dynamic labels with 202 handleWorkflowJob now picks the first matching runner queue that both opts into dynamic labels (matcherConfig.enableDynamicLabels) and accepts every ghr-ec2-* label on the job per its dynamicLabelsPolicy. If no such queue exists, the request is returned with status 202 and a warning is logged instead of falling back to a queue with the dynamic labels stripped. --- .../webhook/src/runners/dispatch.test.ts | 241 +++++++++++++----- .../functions/webhook/src/runners/dispatch.ts | 164 +++++++----- 2 files changed, 282 insertions(+), 123 deletions(-) diff --git a/lambdas/functions/webhook/src/runners/dispatch.test.ts b/lambdas/functions/webhook/src/runners/dispatch.test.ts index 203e0979b7..53afdc3cef 100644 --- a/lambdas/functions/webhook/src/runners/dispatch.test.ts +++ b/lambdas/functions/webhook/src/runners/dispatch.test.ts @@ -183,110 +183,221 @@ describe('Dispatcher', () => { it('should accept job with an exact match and identical labels.', () => { const workflowLabels = ['self-hosted', 'linux', 'x64', 'ubuntu-latest']; const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']]; - expect(canRunJob(workflowLabels, runnerLabels, true, false)).toBe(true); + expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(true); }); it('should accept job with an exact match and identical labels, ignoring cases.', () => { const workflowLabels = ['self-Hosted', 'Linux', 'X64', 'ubuntu-Latest']; const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']]; - expect(canRunJob(workflowLabels, runnerLabels, true, false)).toBe(true); + expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(true); }); it('should accept job with an exact match and runner supports requested capabilities.', () => { const workflowLabels = ['self-hosted', 'linux', 'x64']; const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']]; - expect(canRunJob(workflowLabels, runnerLabels, true, false)).toBe(true); + expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(true); }); it('should NOT accept job with an exact match and runner not matching requested capabilities.', () => { const workflowLabels = ['self-hosted', 'linux', 'x64', 'ubuntu-latest']; const runnerLabels = [['self-hosted', 'linux', 'x64']]; - expect(canRunJob(workflowLabels, runnerLabels, true, false)).toBe(false); + expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(false); }); it('should accept job with for a non exact match. Any label that matches will accept the job.', () => { const workflowLabels = ['self-hosted', 'linux', 'x64', 'ubuntu-latest', 'gpu']; const runnerLabels = [['gpu']]; - expect(canRunJob(workflowLabels, runnerLabels, false, false)).toBe(true); + expect(canRunJob(workflowLabels, runnerLabels, false)).toBe(true); }); it('should NOT accept job with for an exact match. Not all requested capabilities are supported.', () => { const workflowLabels = ['self-hosted', 'linux', 'x64', 'ubuntu-latest', 'gpu']; const runnerLabels = [['gpu']]; - expect(canRunJob(workflowLabels, runnerLabels, true, false)).toBe(false); - }); - - it('should filter out ghr- labels when enableDynamicLabels is true.', () => { - const workflowLabels = ['self-hosted', 'linux', 'x64', 'ghr-ec2-instance-type:t3.large', 'ghr-run-id:12345']; - const runnerLabels = [['self-hosted', 'linux', 'x64']]; - expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(true); - }); - - it('should NOT filter out ghr- labels when enableDynamicLabels is false.', () => { - const workflowLabels = ['self-hosted', 'linux', 'x64', 'ghr-ec2-instance-type:t3.large']; - const runnerLabels = [['self-hosted', 'linux', 'x64']]; - expect(canRunJob(workflowLabels, runnerLabels, true, false)).toBe(false); + expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(false); }); }); - describe('sanitizeGhrLabels (via canRunJob with enableDynamicLabels=true)', () => { - it('should keep valid ghr- labels with allowed characters (alphanumeric, dot, dash, colon, slash)', () => { - const workflowLabels = ['self-hosted', 'ghr-ec2-instance-type:t3.large']; - const runnerLabels = [['self-hosted', 'ghr-ec2-instance-type:t3.large']]; - expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(true); - }); + describe('per-matcher dynamic labels handling', () => { + const baseRunner = runnerConfig[0]; - it('should keep ghr- labels with path-like values containing slashes', () => { - const workflowLabels = ['self-hosted', 'ghr-image:my/custom/image']; - const runnerLabels = [['self-hosted', 'ghr-image:my/custom/image']]; - expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(true); - }); - - it('should strip ghr- labels that exceed 128 characters', () => { - const longLabel = 'ghr-' + 'a'.repeat(125); // 129 chars total, exceeds 128 - const workflowLabels = ['self-hosted', 'linux', longLabel]; - const runnerLabels = [['self-hosted', 'linux']]; - // Long label is stripped, remaining labels match - expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(true); + it('strips invalid ghr- labels (too long, bad chars) before policy and dispatch', async () => { + const longLabel = 'ghr-' + 'a'.repeat(125); // 129 chars + config = await createConfig(undefined, [ + { + ...baseRunner, + matcherConfig: { + labelMatchers: [['self-hosted', 'linux']], + exactMatch: true, + enableDynamicLabels: true, + }, + }, + ]); + const event = { + ...workFlowJobEvent, + workflow_job: { + ...workFlowJobEvent.workflow_job, + labels: ['self-hosted', 'linux', 'ghr-valid:value', 'ghr-bad label', longLabel], + }, + } as unknown as WorkflowJobEvent; + const resp = await dispatch(event, 'workflow_job', config); + expect(resp.statusCode).toBe(201); + expect(sendActionRequest).toHaveBeenCalledWith( + expect.objectContaining({ labels: ['self-hosted', 'linux', 'ghr-valid:value'] }), + ); }); - it('should keep ghr- labels that are exactly 128 characters', () => { - const exactLabel = 'ghr-' + 'a'.repeat(124); // exactly 128 chars - const workflowLabels = ['self-hosted', exactLabel]; - const runnerLabels = [['self-hosted', exactLabel]]; - expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(true); + it('rejects the job (202) when the only matching runner has enableDynamicLabels=false', async () => { + config = await createConfig(undefined, [ + { + ...baseRunner, + matcherConfig: { + labelMatchers: [['self-hosted', 'linux']], + exactMatch: true, + enableDynamicLabels: false, + }, + }, + ]); + const event = { + ...workFlowJobEvent, + workflow_job: { + ...workFlowJobEvent.workflow_job, + labels: ['self-hosted', 'linux', 'ghr-ec2-instance-type:t3.large'], + }, + } as unknown as WorkflowJobEvent; + const resp = await dispatch(event, 'workflow_job', config); + expect(resp.statusCode).toBe(202); + expect(sendActionRequest).not.toHaveBeenCalled(); }); - it('should strip ghr- labels with invalid characters (spaces)', () => { - const workflowLabels = ['self-hosted', 'linux', 'ghr-bad label']; - const runnerLabels = [['self-hosted', 'linux']]; - // Invalid label is stripped, remaining labels match - expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(true); + it('keeps dynamic labels when the matched runner enables them and has no policy', async () => { + config = await createConfig(undefined, [ + { + ...baseRunner, + matcherConfig: { + labelMatchers: [['self-hosted', 'linux']], + exactMatch: true, + enableDynamicLabels: true, + }, + }, + ]); + const event = { + ...workFlowJobEvent, + workflow_job: { + ...workFlowJobEvent.workflow_job, + labels: ['self-hosted', 'linux', 'ghr-ec2-instance-type:t3.large'], + }, + } as unknown as WorkflowJobEvent; + const resp = await dispatch(event, 'workflow_job', config); + expect(resp.statusCode).toBe(201); + expect(sendActionRequest).toHaveBeenCalledWith( + expect.objectContaining({ labels: ['self-hosted', 'linux', 'ghr-ec2-instance-type:t3.large'] }), + ); }); - it('should strip ghr- labels with invalid characters (special chars)', () => { - const workflowLabels = ['self-hosted', 'linux', 'ghr-inject;rm -rf']; - const runnerLabels = [['self-hosted', 'linux']]; - expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(true); + it('skips a matching runner whose policy rejects the dynamic labels and uses the next compliant one', async () => { + config = await createConfig(undefined, [ + { + ...baseRunner, + id: 'strict', + matcherConfig: { + labelMatchers: [['self-hosted', 'linux']], + exactMatch: true, + enableDynamicLabels: true, + dynamicLabelsPolicy: { + allowed_keys: ['instance-type'], + 'instance-type': { allowed: ['m5.*'] }, + }, + }, + }, + { + ...baseRunner, + id: 'permissive', + matcherConfig: { + labelMatchers: [['self-hosted', 'linux']], + exactMatch: true, + enableDynamicLabels: true, + }, + }, + ]); + const event = { + ...workFlowJobEvent, + workflow_job: { + ...workFlowJobEvent.workflow_job, + labels: ['self-hosted', 'linux', 'ghr-ec2-instance-type:t3.large'], + }, + } as unknown as WorkflowJobEvent; + const resp = await dispatch(event, 'workflow_job', config); + expect(resp.statusCode).toBe(201); + expect(sendActionRequest).toHaveBeenCalledWith( + expect.objectContaining({ + queueId: 'permissive', + labels: ['self-hosted', 'linux', 'ghr-ec2-instance-type:t3.large'], + }), + ); }); - it('should never strip non-ghr labels regardless of content', () => { - const workflowLabels = ['self-hosted', 'linux', 'x64']; - const runnerLabels = [['self-hosted', 'linux', 'x64']]; - expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(true); + it('rejects the job (202) when no runner accepts the policy', async () => { + config = await createConfig(undefined, [ + { + ...baseRunner, + id: 'first', + matcherConfig: { + labelMatchers: [['self-hosted', 'linux']], + exactMatch: true, + enableDynamicLabels: true, + dynamicLabelsPolicy: { + allowed_keys: ['instance-type'], + 'instance-type': { allowed: ['m5.*'] }, + }, + }, + }, + { + ...baseRunner, + id: 'second', + matcherConfig: { + labelMatchers: [['self-hosted', 'linux']], + exactMatch: true, + enableDynamicLabels: false, + }, + }, + ]); + const event = { + ...workFlowJobEvent, + workflow_job: { + ...workFlowJobEvent.workflow_job, + labels: ['self-hosted', 'linux', 'ghr-ec2-instance-type:t3.large'], + }, + } as unknown as WorkflowJobEvent; + const resp = await dispatch(event, 'workflow_job', config); + expect(resp.statusCode).toBe(202); + expect(sendActionRequest).not.toHaveBeenCalled(); }); - it('should handle a mix of valid ghr-, invalid ghr-, and regular labels', () => { - const longLabel = 'ghr-' + 'x'.repeat(125); // 129 chars, will be stripped - const workflowLabels = [ - 'self-hosted', - 'linux', - 'ghr-valid:value', // valid, kept - 'ghr-bad label', // invalid chars, stripped - longLabel, // too long, stripped - ]; - const runnerLabels = [['self-hosted', 'linux', 'ghr-valid:value']]; - expect(canRunJob(workflowLabels, runnerLabels, true, true)).toBe(true); + it('forwards non-dynamic jobs as-is to the first match', async () => { + config = await createConfig(undefined, [ + { + ...baseRunner, + id: 'first', + matcherConfig: { + labelMatchers: [['self-hosted', 'linux']], + exactMatch: true, + enableDynamicLabels: true, + dynamicLabelsPolicy: { allowed_keys: [] }, + }, + }, + ]); + const event = { + ...workFlowJobEvent, + workflow_job: { + ...workFlowJobEvent.workflow_job, + labels: ['self-hosted', 'linux'], + }, + } as unknown as WorkflowJobEvent; + const resp = await dispatch(event, 'workflow_job', config); + expect(resp.statusCode).toBe(201); + expect(sendActionRequest).toHaveBeenCalledWith( + expect.objectContaining({ queueId: 'first', labels: ['self-hosted', 'linux'] }), + ); }); }); }); diff --git a/lambdas/functions/webhook/src/runners/dispatch.ts b/lambdas/functions/webhook/src/runners/dispatch.ts index e7f4dcb623..e40b053ca5 100644 --- a/lambdas/functions/webhook/src/runners/dispatch.ts +++ b/lambdas/functions/webhook/src/runners/dispatch.ts @@ -5,9 +5,13 @@ import { Response } from '../lambda'; import { RunnerMatcherConfig, sendActionRequest } from '../sqs'; import ValidationError from '../ValidationError'; import { ConfigDispatcher, ConfigWebhook } from '../ConfigLoader'; +import { violationsAgainstPolicy } from './dynamic-labels-policy'; const logger = createChildLogger('handler'); +const GHR_LABEL_MAX_LENGTH = 128; +const GHR_LABEL_VALUE_PATTERN = /^[a-zA-Z0-9._/\-:]+$/; + export async function dispatch( event: WorkflowJobEvent, eventType: string, @@ -15,7 +19,7 @@ export async function dispatch( ): Promise { validateRepoInAllowList(event, config); - return await handleWorkflowJob(event, eventType, config.matcherConfig!, config.enableDynamicLabels); + return await handleWorkflowJob(event, eventType, config.matcherConfig!); } function validateRepoInAllowList(event: WorkflowJobEvent, config: ConfigDispatcher) { @@ -29,7 +33,6 @@ async function handleWorkflowJob( body: WorkflowJobEvent, githubEvent: string, matcherConfig: Array, - enableDynamicLabels: boolean, ): Promise { if (body.action !== 'queued') { return { @@ -43,39 +46,93 @@ 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 queues by priority (exactMatch first), as before. matcherConfig.sort((a, b) => { return a.matcherConfig.exactMatch === b.matcherConfig.exactMatch ? 0 : a.matcherConfig.exactMatch ? -1 : 1; }); - for (const queue of matcherConfig) { - if ( - canRunJob( - body.workflow_job.labels, - queue.matcherConfig.labelMatchers, - queue.matcherConfig.exactMatch, - enableDynamicLabels, - ) - ) { - await sendActionRequest({ - id: body.workflow_job.id, - repositoryName: body.repository.name, - repositoryOwner: body.repository.owner.login, - eventType: githubEvent, - installationId: body.installation?.id ?? 0, - queueId: queue.id, - repoOwnerType: body.repository.owner.type, - labels: body.workflow_job.labels, - }); - logger.info( - `Successfully dispatched job for ${body.repository.full_name} to the queue ${queue.id} - ` + - `Job ID: ${body.workflow_job.id}, Job Name: ${body.workflow_job.name}, Run ID: ${body.workflow_job.run_id}`, + + const allLabels = body.workflow_job.labels; + const ghrLabels = allLabels.filter((l) => l.startsWith('ghr-')); + const sanitizedGhrLabels = sanitizeGhrLabels(ghrLabels); + const nonGhrLabels = allLabels.filter((l) => !l.startsWith('ghr-')); + const hasDynamicLabels = sanitizedGhrLabels.length > 0; + + // 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), + ); + + if (matches.length === 0) { + return notAccepted(body); + } + + // 2. Pick a queue. + let chosen: RunnerMatcherConfig; + let labelsToSend: string[]; + + if (!hasDynamicLabels) { + // No dynamic labels in the job: take the first match, forward as-is. + chosen = matches[0]; + labelsToSend = nonGhrLabels; + } else { + // Dynamic labels present: prefer the first match that has dynamic labels + // enabled AND accepts these labels under its policy. + let compliant: RunnerMatcherConfig | undefined; + for (const q of matches) { + if (!q.matcherConfig.enableDynamicLabels) { + logger.warn( + `Queue ${q.id} matches non-dynamic labels but does not allow dynamic labels; trying next match`, + ); + continue; + } + const violations = violationsAgainstPolicy(sanitizedGhrLabels, q.matcherConfig.dynamicLabelsPolicy); + if (violations.length === 0) { + compliant = q; + break; + } + for (const v of violations) { + logger.warn( + `Queue ${q.id}: dynamic label '${v.label}' does not match policy (${v.reason}); trying next match`, + ); + } + } + + if (compliant) { + chosen = compliant; + labelsToSend = [...nonGhrLabels, ...sanitizedGhrLabels]; + } else { + // No queue accepts the dynamic labels under its policy: refuse the job. + logger.warn( + `No queue accepts the dynamic labels for this job; not dispatching`, + { dynamicLabels: sanitizedGhrLabels }, ); - return { - statusCode: 201, - body: `Successfully queued job for ${body.repository.full_name} to the queue ${queue.id}`, - }; + return notAccepted(body); } } + + await sendActionRequest({ + id: body.workflow_job.id, + repositoryName: body.repository.name, + repositoryOwner: body.repository.owner.login, + eventType: githubEvent, + installationId: body.installation?.id ?? 0, + queueId: chosen.id, + repoOwnerType: body.repository.owner.type, + labels: labelsToSend, + }); + + logger.info( + `Successfully dispatched job for ${body.repository.full_name} to the queue ${chosen.id} - ` + + `Job ID: ${body.workflow_job.id}, Job Name: ${body.workflow_job.name}, Run ID: ${body.workflow_job.run_id}`, + ); + return { + statusCode: 201, + body: `Successfully queued job for ${body.repository.full_name} to the queue ${chosen.id}`, + }; +} + +function notAccepted(body: WorkflowJobEvent): Response { const notAcceptedErrorMsg = `Received event contains runner labels '${body.workflow_job.labels}' from '${ body.repository.full_name }' that are not accepted.`; @@ -86,47 +143,38 @@ async function handleWorkflowJob( } function sanitizeGhrLabels(labels: string[]): string[] { - const GHR_LABEL_MAX_LENGTH = 128; - const GHR_LABEL_VALUE_PATTERN = /^[a-zA-Z0-9._/\-:]+$/; - - return labels - .map((label) => { - if (!label.startsWith('ghr-')) return label; - - if (label.length > GHR_LABEL_MAX_LENGTH) { - logger.warn('Dynamic label exceeds max length, stripping', { label: label.substring(0, 40) }); - return null; - } - if (!GHR_LABEL_VALUE_PATTERN.test(label)) { - logger.warn('Dynamic label contains invalid characters, stripping', { label }); - return null; - } - return null; - }) - .filter((l): l is string => l !== null); + return labels.filter((label) => { + if (label.length > GHR_LABEL_MAX_LENGTH) { + logger.warn('Dynamic label exceeds max length, stripping', { label: label.substring(0, 40) }); + return false; + } + if (!GHR_LABEL_VALUE_PATTERN.test(label)) { + logger.warn('Dynamic label contains invalid characters, stripping', { label }); + return false; + } + return true; + }); } +/** + * Pure label match against a runner's `labelMatchers`. Caller is expected to + * pass only non-dynamic labels. + */ export function canRunJob( workflowJobLabels: string[], runnerLabelsMatchers: string[][], workflowLabelCheckAll: boolean, - enableDynamicLabels: boolean, ): boolean { - // Filter out ghr- labels only and sanitize them if dynamic labels is enabled, otherwise keep all labels as is for matching. - const sanitizedLabels = enableDynamicLabels ? sanitizeGhrLabels(workflowJobLabels) : workflowJobLabels; - - runnerLabelsMatchers = runnerLabelsMatchers.map((runnerLabel) => { - return runnerLabel.map((label) => label.toLowerCase()); - }); + const lowered = runnerLabelsMatchers.map((rl) => rl.map((l) => l.toLowerCase())); const matchLabels = workflowLabelCheckAll - ? runnerLabelsMatchers.some((rl) => sanitizedLabels.every((wl) => rl.includes(wl.toLowerCase()))) - : runnerLabelsMatchers.some((rl) => sanitizedLabels.some((wl) => rl.includes(wl.toLowerCase()))); - const match = sanitizedLabels.length === 0 ? !matchLabels : matchLabels; + ? 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; logger.debug( `Received workflow job event with labels: '${JSON.stringify(workflowJobLabels)}'. The event does ${ match ? '' : 'NOT ' - }match the runner labels: '${Array.from(runnerLabelsMatchers).join(',')}'`, + }match the runner labels: '${Array.from(lowered).join(',')}'`, ); return match; } From 50571b11853b8d8547ce93b3c300ccd3fbc99615 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 12 Jun 2026 23:29:35 +0200 Subject: [PATCH 05/12] refactor(control-plane): drop dynamic-labels env gate from scale-up The dispatcher is now the sole gatekeeper for dynamic labels, so scale-up no longer reads ENABLE_DYNAMIC_LABELS or applies a policy. It simply forwards every ghr-ec2-* label that survived dispatch into the EC2 override config. --- .../src/scale-runners/scale-up.test.ts | 34 ++++------- .../src/scale-runners/scale-up.ts | 60 +++++++++---------- 2 files changed, 39 insertions(+), 55 deletions(-) diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts index 1770dfa09a..2fd0a3d530 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts @@ -595,7 +595,6 @@ describe('scaleUp with GHES', () => { describe('Dynamic EC2 Configuration', () => { beforeEach(() => { process.env.ENABLE_ORGANIZATION_RUNNERS = 'true'; - process.env.ENABLE_DYNAMIC_LABELS = 'true'; process.env.ENABLE_EPHEMERAL_RUNNERS = 'true'; process.env.ENABLE_JOB_QUEUED_CHECK = 'false'; process.env.RUNNER_LABELS = 'base-label'; @@ -690,29 +689,6 @@ describe('scaleUp with GHES', () => { ); }); - it('does not process EC2 labels when ENABLE_DYNAMIC_LABELS is disabled', async () => { - process.env.ENABLE_DYNAMIC_LABELS = 'false'; - - const testDataWithEc2Labels = [ - { - ...TEST_DATA_SINGLE, - labels: ['ghr-ec2-instance-type:c5.4xlarge'], - messageId: 'test-7', - }, - ]; - - await scaleUpModule.scaleUp(testDataWithEc2Labels); - - // Should ignore EC2 labels and use default instance types - expect(createRunner).toBeCalledWith( - expect.objectContaining({ - ec2instanceCriteria: expect.objectContaining({ - instanceTypes: ['t3.medium', 't3.large'], - }), - }), - ); - }); - it('handles multiple EC2 labels correctly', async () => { const testDataWithMultipleEc2Labels = [ { @@ -2464,6 +2440,11 @@ describe('parseEc2OverrideConfig', () => { expect(result?.Placement?.GroupName).toBe('my-placement-group'); }); + it('should parse placement-group-id label', () => { + const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-placement-group-id:pg-0123456789abcdef0']); + expect(result?.Placement?.GroupId).toBe('pg-0123456789abcdef0'); + }); + it('should parse placement-tenancy label', () => { const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-placement-tenancy:dedicated']); expect(result?.Placement?.Tenancy).toBe('dedicated'); @@ -2575,6 +2556,11 @@ describe('parseEc2OverrideConfig', () => { expect(result?.BlockDeviceMappings?.[0]?.VirtualName).toBe('ephemeral0'); }); + it('should parse block-device-name label', () => { + const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-block-device-name:/dev/xvda']); + expect(result?.BlockDeviceMappings?.[0]?.DeviceName).toBe('/dev/xvda'); + }); + it('should parse block-device-no-device label', () => { const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-block-device-no-device:true']); expect(result?.BlockDeviceMappings?.[0]?.NoDevice).toBe('true'); diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up.ts index b742264842..e692b5ff56 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.ts @@ -336,7 +336,6 @@ export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise l.startsWith('ghr-'))?.slice('ghr-'.length); - - if (dynamicLabels) { - const dynamicLabelsHash = labelsHash(labels); - key = `${key}/${dynamicLabelsHash}`; - } + if (labels?.some((l) => l.startsWith('ghr-'))) { + const dynamicLabelsHash = labelsHash(labels); + key = `${key}/${dynamicLabelsHash}`; } let entry = validMessages.get(key); @@ -457,27 +452,23 @@ export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise 0 && dynamicLabelsEnabled) { - logger.debug('Dynamic EC2 config enabled, processing labels', { labels: messages[0].labels }); - - const dynamicEC2Labels = messages[0].labels?.map((l) => l.trim()).filter((l) => l.startsWith('ghr-ec2-')) ?? []; - const allDynamicLabels = messages[0].labels?.map((l) => l.trim()).filter((l) => l.startsWith('ghr-')) ?? []; - - if (allDynamicLabels.length > 0) { - runnerLabels = runnerLabels ? `${runnerLabels},${allDynamicLabels.join(',')}` : allDynamicLabels.join(','); - - logger.debug('Updated runner labels', { runnerLabels }); - - if (dynamicEC2Labels.length > 0) { - ec2OverrideConfig = parseEc2OverrideConfig(dynamicEC2Labels); - if (ec2OverrideConfig) { - logger.debug('EC2 override config parsed from labels', { - ec2OverrideConfig, - }); - } + const messageLabels = messages.length > 0 ? messages[0].labels ?? [] : []; + const dynamicEC2Labels = messageLabels.map((l) => l.trim()).filter((l) => l.startsWith('ghr-ec2-')); + const nonEc2DynamicLabels = messageLabels + .map((l) => l.trim()) + .filter((l) => l.startsWith('ghr-') && !l.startsWith('ghr-ec2-')); + const allDynamicLabels = [...nonEc2DynamicLabels, ...dynamicEC2Labels]; + + if (allDynamicLabels.length > 0) { + logger.debug('Dynamic labels present on message', { labels: allDynamicLabels }); + runnerLabels = runnerLabels ? `${runnerLabels},${allDynamicLabels.join(',')}` : allDynamicLabels.join(','); + logger.debug('Updated runner labels', { runnerLabels }); + + if (dynamicEC2Labels.length > 0) { + ec2OverrideConfig = parseEc2OverrideConfig(dynamicEC2Labels); + if (ec2OverrideConfig) { + logger.debug('EC2 override config parsed from labels', { ec2OverrideConfig }); } - } else { - logger.debug('No dynamic labels found on message'); } } @@ -822,8 +813,8 @@ async function createJitConfig( * - ghr-ec2-accelerator-count-max: - Set maximum accelerator count * - ghr-ec2-accelerator-manufacturers: - Accelerator manufacturers (comma-separated: nvidia,amd,amazon-web-services,xilinx) * - ghr-ec2-accelerator-names: - Specific accelerator names (comma-separated) - * - ghr-ec2-accelerator-memory-mib-min: - Min accelerator total memory in MiB - * - ghr-ec2-accelerator-memory-mib-max: - Max accelerator total memory in MiB + * - ghr-ec2-accelerator-total-memory-mib-min: - Min accelerator total memory in MiB + * - ghr-ec2-accelerator-total-memory-mib-max: - Max accelerator total memory in MiB * * Instance Requirements (Network & Storage): * - ghr-ec2-network-interface-count-min: - Min network interfaces @@ -839,6 +830,7 @@ async function createJitConfig( * * Placement: * - ghr-ec2-placement-group: - Placement group name + * - ghr-ec2-placement-group-id: - Placement group ID (alternative to placement-group) * - ghr-ec2-placement-tenancy: - Tenancy (default,dedicated,host) * - ghr-ec2-placement-host-id: - Dedicated host ID * - ghr-ec2-placement-affinity: - Affinity (default,host) @@ -848,6 +840,7 @@ async function createJitConfig( * - ghr-ec2-placement-host-resource-group-arn: - Host resource group ARN * * Block Device Mappings: + * - ghr-ec2-block-device-name: - Device name (e.g. /dev/xvda) — must match the launch template entry being overridden * - ghr-ec2-ebs-volume-size: - EBS volume size in GB * - ghr-ec2-ebs-volume-type: - EBS volume type (gp2,gp3,io1,io2,st1,sc1) * - ghr-ec2-ebs-iops: - EBS IOPS @@ -908,6 +901,8 @@ export function parseEc2OverrideConfig(labels: string[]): Ec2OverrideConfig | un const placementKey = key.replace('placement-', ''); if (placementKey === 'group') { config.Placement.GroupName = value; + } else if (placementKey === 'group-id') { + config.Placement.GroupId = value; } else if (placementKey === 'tenancy') { config.Placement.Tenancy = value as Tenancy; } else if (placementKey === 'host-id') { @@ -952,7 +947,10 @@ export function parseEc2OverrideConfig(labels: string[]): Ec2OverrideConfig | un } // Block Device Mappings (Non-EBS) - else if (key === 'block-device-virtual-name') { + else if (key === 'block-device-name') { + config.BlockDeviceMappings = config.BlockDeviceMappings || ([{}] as FleetBlockDeviceMappingRequest[]); + config.BlockDeviceMappings[0].DeviceName = value; + } else if (key === 'block-device-virtual-name') { config.BlockDeviceMappings = config.BlockDeviceMappings || ([{}] as FleetBlockDeviceMappingRequest[]); config.BlockDeviceMappings[0].VirtualName = value; } else if (key === 'block-device-no-device') { From ce220f6f11cc01c66666467dfbb79ef5bd9bd166 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 12 Jun 2026 23:39:34 +0200 Subject: [PATCH 06/12] feat(multi-runner): nest enableDynamicLabels and policy in matcherConfig Move the dynamic-labels opt-in and the per-runner policy from the runner_config block into matcherConfig, so they ride with the rest of the matcher metadata into the webhook lambda. Stop passing enable_dynamic_labels and dynamic_labels_policy to the runners module (the dispatcher is the gatekeeper) and stop OR-ing the flag at the webhook module boundary. --- modules/multi-runner/main.tf | 9 ++++++++- modules/multi-runner/runners.tf | 1 - modules/multi-runner/variables.tf | 16 +++++++--------- modules/multi-runner/webhook.tf | 2 -- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/modules/multi-runner/main.tf b/modules/multi-runner/main.tf index 905cc7f793..caef8fdbcc 100644 --- a/modules/multi-runner/main.tf +++ b/modules/multi-runner/main.tf @@ -11,7 +11,14 @@ locals { runner_extra_labels = { for k, v in var.multi_runner_config : k => sort(setunion(flatten(v.matcherConfig.labelMatchers), compact(v.runner_config.runner_extra_labels))) } - runner_config = { for k, v in var.multi_runner_config : k => merge({ id = aws_sqs_queue.queued_builds[k].id, arn = aws_sqs_queue.queued_builds[k].arn, url = aws_sqs_queue.queued_builds[k].url }, merge(v, { runner_config = merge(v.runner_config, { runner_extra_labels = local.runner_extra_labels[k] }) })) } + runner_config = { for k, v in var.multi_runner_config : k => merge( + { + id = aws_sqs_queue.queued_builds[k].id + arn = aws_sqs_queue.queued_builds[k].arn + url = aws_sqs_queue.queued_builds[k].url + }, + merge(v, { runner_config = merge(v.runner_config, { runner_extra_labels = local.runner_extra_labels[k] }) }), + ) } tmp_distinct_list_unique_os_and_arch = distinct([for i, config in local.runner_config : { "os_type" : config.runner_config.runner_os, "architecture" : config.runner_config.runner_architecture } if config.runner_config.enable_runner_binaries_syncer]) unique_os_and_arch = { for i, v in local.tmp_distinct_list_unique_os_and_arch : "${v.os_type}_${v.architecture}" => v } diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf index 2e92166eb9..0fc6f61e61 100644 --- a/modules/multi-runner/runners.tf +++ b/modules/multi-runner/runners.tf @@ -35,7 +35,6 @@ module "runners" { scale_errors = each.value.runner_config.scale_errors enable_organization_runners = each.value.runner_config.enable_organization_runners enable_ephemeral_runners = each.value.runner_config.enable_ephemeral_runners - enable_dynamic_labels = var.enable_dynamic_labels enable_jit_config = each.value.runner_config.enable_jit_config enable_job_queued_check = each.value.runner_config.enable_job_queued_check disable_runner_autoupdate = each.value.runner_config.disable_runner_autoupdate diff --git a/modules/multi-runner/variables.tf b/modules/multi-runner/variables.tf index 78b9ec2a25..cf57754cf9 100644 --- a/modules/multi-runner/variables.tf +++ b/modules/multi-runner/variables.tf @@ -199,9 +199,11 @@ 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) + priority = optional(number, 999) + enableDynamicLabels = optional(bool, false) + dynamicLabelsPolicy = optional(any, null) }) redrive_build_queue = optional(object({ enabled = bool @@ -224,7 +226,6 @@ variable "multi_runner_config" { disable_runner_autoupdate: "Disable the auto update of the github runner agent. Be aware there is a grace period of 30 days, see also the [GitHub article](https://github.blog/changelog/2022-02-01-github-actions-self-hosted-runners-can-now-disable-automatic-updates/)" ebs_optimized: "The EC2 EBS optimized configuration." enable_ephemeral_runners: "Enable ephemeral runners, runners will only be used once." - enable_dynamic_labels: "Experimental! Can be removed / changed without trigger a major release. Enable dynamic labels with 'ghr-' prefix. When enabled, jobs can use 'ghr-ec2-:' labels to dynamically configure EC2 instances (e.g., 'ghr-ec2-instance-type:t3.large') and 'ghr-run-