Skip to content

Commit 8d677ce

Browse files
feat(runner-providers): add MicroVM webhook routing
1 parent c016a9d commit 8d677ce

9 files changed

Lines changed: 288 additions & 1 deletion

File tree

lambdas/libs/runner-providers/aws/ec2/src/webhook/dynamic-labels.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createChildLogger } from '@aws-github-runner/aws-powertools-util';
22

33
import type { DynamicLabelDispatchTarget, DynamicLabelProvider, RunnerMatcherConfig } from '../../../../contracts';
4+
import { dynamicLabelsForOtherProvider } from '../../../../dynamic-labels';
45
import { violationsAgainstPolicy } from './dynamic-labels-policy';
56

67
const logger = createChildLogger('handler');
@@ -34,6 +35,14 @@ export function selectEc2DynamicLabelQueue(
3435
continue;
3536
}
3637

38+
const labelsForOtherProvider = dynamicLabelsForOtherProvider(sanitizedGhrLabels, 'ec2');
39+
if (labelsForOtherProvider.length > 0) {
40+
logger.warn(`Queue ${queue.id}: dynamic labels target another runner provider; trying next match`, {
41+
dynamicLabels: labelsForOtherProvider,
42+
});
43+
continue;
44+
}
45+
3746
const violations = violationsAgainstPolicy(sanitizedGhrLabels, resolveEc2DynamicLabelsPolicy(queue));
3847
if (violations.length === 0) {
3948
return {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { expect, it, vi } from 'vitest';
2+
3+
import { provider as controlPlaneProvider } from './control-plane';
4+
import { provider as webhookProvider } from './webhook';
5+
6+
it('exposes every MicroVM provider capability from its lane entry point', () => {
7+
const controlPlanePlugin = controlPlaneProvider.createPlugin(vi.fn(async () => []));
8+
const webhookPlugin = webhookProvider.createPlugin();
9+
10+
expect(controlPlanePlugin.type).toBe('microvm');
11+
expect(controlPlanePlugin.capabilities.pool()).toEqual({
12+
listRunners: expect.any(Function),
13+
countAvailableRunners: expect.any(Function),
14+
createRunners: expect.any(Function),
15+
});
16+
expect(controlPlanePlugin.capabilities.scaleUp()).toEqual({
17+
resolveLabelsForRunners: expect.any(Function),
18+
getCurrentRunners: expect.any(Function),
19+
createRunners: expect.any(Function),
20+
});
21+
expect(controlPlanePlugin.capabilities.scaleDown()).toEqual({
22+
list: expect.any(Function),
23+
bootTimeExceeded: expect.any(Function),
24+
markOrphan: expect.any(Function),
25+
unmarkOrphan: expect.any(Function),
26+
terminate: expect.any(Function),
27+
});
28+
expect(webhookPlugin.type).toBe('microvm');
29+
expect(webhookPlugin.capabilities.dynamicLabels.selectQueue).toEqual(expect.any(Function));
30+
});
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import type { RunnerMatcherConfig } from '../../../../contracts';
4+
import { microvmDynamicLabelProvider } from './dynamic-labels';
5+
6+
const imageArn = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner-large';
7+
const egressConnectorArn = 'arn:aws:lambda:eu-west-1:123456789012:network-connector:github-runner-private-egress';
8+
9+
describe('microvmDynamicLabelProvider', () => {
10+
it('accepts supported MicroVM overrides', () => {
11+
const queue = microvmQueue();
12+
const dynamicLabels = [
13+
`ghr-microvm-egress-network-connectors:${egressConnectorArn}`,
14+
`ghr-microvm-image-arn:${imageArn}`,
15+
'ghr-microvm-image-version:3.0',
16+
'ghr-microvm-maximum-duration-in-seconds:7200',
17+
];
18+
19+
expect(
20+
microvmDynamicLabelProvider.selectQueue({
21+
queue,
22+
nonGhrLabels: ['self-hosted', 'microvm'],
23+
sanitizedGhrLabels: dynamicLabels,
24+
}),
25+
).toEqual({ queue, labels: ['self-hosted', 'microvm', ...dynamicLabels] });
26+
});
27+
28+
it('rejects unsupported MicroVM resource overrides', () => {
29+
expect(
30+
microvmDynamicLabelProvider.selectQueue({
31+
queue: microvmQueue(),
32+
nonGhrLabels: ['self-hosted', 'microvm'],
33+
sanitizedGhrLabels: ['ghr-microvm-memory:8192'],
34+
}),
35+
).toBeUndefined();
36+
});
37+
38+
it('rejects labels owned by another runner provider', () => {
39+
expect(
40+
microvmDynamicLabelProvider.selectQueue({
41+
queue: microvmQueue(),
42+
nonGhrLabels: ['self-hosted', 'microvm'],
43+
sanitizedGhrLabels: ['ghr-ec2-instance-type:m7i.large'],
44+
}),
45+
).toBeUndefined();
46+
});
47+
48+
it('requires dynamic labels to be enabled for the queue', () => {
49+
const queue = microvmQueue();
50+
queue.matcherConfig.enableDynamicLabels = false;
51+
52+
expect(
53+
microvmDynamicLabelProvider.selectQueue({
54+
queue,
55+
nonGhrLabels: ['self-hosted', 'microvm'],
56+
sanitizedGhrLabels: ['ghr-microvm-image-version:3.0'],
57+
}),
58+
).toBeUndefined();
59+
});
60+
61+
it('enforces the AWS dynamic-label policy', () => {
62+
const queue = microvmQueue();
63+
queue.matcherConfig.awsDynamicLabelsPolicy = {
64+
restricted_keys: { 'maximum-duration-in-seconds': { max: 3600 } },
65+
};
66+
67+
expect(
68+
microvmDynamicLabelProvider.selectQueue({
69+
queue,
70+
nonGhrLabels: ['self-hosted', 'microvm'],
71+
sanitizedGhrLabels: ['ghr-microvm-maximum-duration-in-seconds:7200'],
72+
}),
73+
).toBeUndefined();
74+
});
75+
76+
it('applies allowed patterns to the complete image ARN', () => {
77+
const queue = microvmQueue();
78+
queue.matcherConfig.awsDynamicLabelsPolicy = {
79+
restricted_keys: {
80+
'image-arn': {
81+
allowed: ['arn:aws:lambda:eu-west-1:123456789012:microvm-image:approved-*'],
82+
},
83+
},
84+
};
85+
86+
expect(
87+
microvmDynamicLabelProvider.selectQueue({
88+
queue,
89+
nonGhrLabels: ['self-hosted', 'microvm'],
90+
sanitizedGhrLabels: [
91+
'ghr-microvm-image-arn:arn:aws:lambda:eu-west-1:123456789012:microvm-image:approved-large',
92+
],
93+
}),
94+
).toBeDefined();
95+
expect(
96+
microvmDynamicLabelProvider.selectQueue({
97+
queue,
98+
nonGhrLabels: ['self-hosted', 'microvm'],
99+
sanitizedGhrLabels: ['ghr-microvm-image-arn:arn:aws:lambda:eu-west-1:123456789012:microvm-image:unapproved'],
100+
}),
101+
).toBeUndefined();
102+
});
103+
104+
it('applies the policy to each egress connector label', () => {
105+
const queue = microvmQueue();
106+
queue.matcherConfig.awsDynamicLabelsPolicy = {
107+
restricted_keys: {
108+
'egress-network-connectors': {
109+
allowed: ['arn:aws:lambda:eu-west-1:123456789012:network-connector:approved-*'],
110+
},
111+
},
112+
};
113+
114+
expect(
115+
microvmDynamicLabelProvider.selectQueue({
116+
queue,
117+
nonGhrLabels: ['self-hosted', 'microvm'],
118+
sanitizedGhrLabels: [
119+
'ghr-microvm-egress-network-connectors:arn:aws:lambda:eu-west-1:123456789012:network-connector:approved-private',
120+
],
121+
}),
122+
).toBeDefined();
123+
expect(
124+
microvmDynamicLabelProvider.selectQueue({
125+
queue,
126+
nonGhrLabels: ['self-hosted', 'microvm'],
127+
sanitizedGhrLabels: [
128+
'ghr-microvm-egress-network-connectors:arn:aws:lambda:eu-west-1:123456789012:network-connector:unapproved',
129+
],
130+
}),
131+
).toBeUndefined();
132+
});
133+
});
134+
135+
function microvmQueue(): RunnerMatcherConfig {
136+
return {
137+
id: 'microvm',
138+
arn: 'arn:aws:sqs:eu-west-1:123456789012:microvm',
139+
runnerProvider: 'microvm',
140+
matcherConfig: {
141+
labelMatchers: [['self-hosted', 'linux', 'arm64', 'microvm']],
142+
exactMatch: false,
143+
enableDynamicLabels: true,
144+
},
145+
};
146+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { createChildLogger } from '@aws-github-runner/aws-powertools-util';
2+
3+
import { violationsAgainstAwsDynamicLabelsPolicy } from '../../../dynamic-labels-policy';
4+
import type { DynamicLabelDispatchTarget, DynamicLabelProvider, RunnerMatcherConfig } from '../../../../contracts';
5+
import { dynamicLabelsForOtherProvider } from '../../../../dynamic-labels';
6+
import { MICROVM_DYNAMIC_LABEL_PREFIX, parseMicrovmDynamicLabels } from '../dynamic-labels';
7+
8+
const logger = createChildLogger('handler');
9+
10+
export function selectMicrovmDynamicLabelQueue(
11+
matches: RunnerMatcherConfig[],
12+
nonGhrLabels: string[],
13+
sanitizedGhrLabels: string[],
14+
): DynamicLabelDispatchTarget | undefined {
15+
for (const queue of matches) {
16+
if (!queue.matcherConfig.enableDynamicLabels) {
17+
logger.warn(`Queue ${queue.id} matches non-dynamic labels but does not allow dynamic labels; trying next match`);
18+
continue;
19+
}
20+
21+
const labelsForOtherProvider = dynamicLabelsForOtherProvider(sanitizedGhrLabels, 'microvm');
22+
if (labelsForOtherProvider.length > 0) {
23+
logger.warn(`Queue ${queue.id}: dynamic labels target another runner provider; trying next match`, {
24+
dynamicLabels: labelsForOtherProvider,
25+
});
26+
continue;
27+
}
28+
29+
const parsedLabels = parseMicrovmDynamicLabels(sanitizedGhrLabels);
30+
const policyViolations = violationsAgainstAwsDynamicLabelsPolicy(
31+
sanitizedGhrLabels,
32+
queue.matcherConfig.awsDynamicLabelsPolicy,
33+
MICROVM_DYNAMIC_LABEL_PREFIX,
34+
);
35+
const violations = [...parsedLabels.violations, ...policyViolations];
36+
37+
if (violations.length === 0) {
38+
return { queue, labels: [...nonGhrLabels, ...sanitizedGhrLabels] };
39+
}
40+
41+
for (const violation of violations) {
42+
logger.warn(
43+
`Queue ${queue.id}: dynamic label '${violation.label}' is not accepted (${violation.reason}); trying next match`,
44+
);
45+
}
46+
}
47+
48+
return undefined;
49+
}
50+
51+
export const microvmDynamicLabelProvider: DynamicLabelProvider = {
52+
selectQueue: ({ queue, nonGhrLabels, sanitizedGhrLabels }) =>
53+
selectMicrovmDynamicLabelQueue([queue], nonGhrLabels, sanitizedGhrLabels),
54+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { RunnerProviderPlugin } from '../../core';
2+
3+
import type { WebhookProviderCapabilities, WebhookProviderModule } from '../../contracts';
4+
import { microvmDynamicLabelProvider } from './src/webhook/dynamic-labels';
5+
6+
export function createMicrovmWebhookPlugin(): RunnerProviderPlugin<WebhookProviderCapabilities, 'microvm'> {
7+
return {
8+
type: 'microvm',
9+
capabilities: { dynamicLabels: microvmDynamicLabelProvider },
10+
};
11+
}
12+
13+
export const provider = {
14+
type: 'microvm',
15+
createPlugin: createMicrovmWebhookPlugin,
16+
} satisfies WebhookProviderModule<'microvm'>;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { runnerProviderTypes } from './provider-types';
2+
import type { RunnerProviderType } from './provider-types';
3+
4+
export function dynamicLabelsForOtherProvider(labels: string[], provider: RunnerProviderType): string[] {
5+
return labels.filter((label) =>
6+
runnerProviderTypes.some((candidate) => candidate !== provider && label.startsWith(`ghr-${candidate}-`)),
7+
);
8+
}

lambdas/libs/runner-providers/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"./aws/ec2/control-plane": "./aws/ec2/control-plane.ts",
1313
"./aws/ec2/control-plane/runners": "./aws/ec2/src/control-plane/runners.ts",
1414
"./aws/ec2/control-plane/runner-config": "./aws/ec2/src/control-plane/runner-config.ts",
15+
"./aws/microvm/webhook": "./aws/microvm/webhook.ts",
1516
"./aws/microvm/control-plane": "./aws/microvm/control-plane.ts"
1617
},
1718
"type": "module",
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { provider as ec2 } from './aws/ec2/webhook';
2+
import { provider as microvm } from './aws/microvm/webhook';
23
import type { WebhookProviderModule } from './contracts';
34

45
/** Provider plugins included in the webhook bundle. */
5-
export const enabledWebhookProviders = [ec2] as const satisfies readonly WebhookProviderModule[];
6+
export const enabledWebhookProviders = [ec2, microvm] as const satisfies readonly WebhookProviderModule[];

lambdas/libs/runner-providers/webhook.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,28 @@ describe('selectDynamicLabelQueue', () => {
3232
selectDynamicLabelQueue([queue], ['self-hosted', 'linux'], ['ghr-ec2-instance-type:t3.large']),
3333
).toThrow(`Unsupported runner provider type '${String(runnerProvider)}'`);
3434
});
35+
36+
it('skips EC2 and selects the MicroVM queue for MicroVM override labels', () => {
37+
const ec2Queue = runnerQueue('ec2', 'ec2');
38+
const microvmQueue = runnerQueue('microvm', 'microvm');
39+
const imageVersionLabel = 'ghr-microvm-image-version:3.0';
40+
41+
expect(selectDynamicLabelQueue([ec2Queue, microvmQueue], ['self-hosted', 'linux'], [imageVersionLabel])).toEqual({
42+
queue: microvmQueue,
43+
labels: ['self-hosted', 'linux', imageVersionLabel],
44+
});
45+
});
46+
47+
it('skips MicroVM and selects the EC2 queue for EC2 override labels', () => {
48+
const microvmQueue = runnerQueue('microvm', 'microvm');
49+
const ec2Queue = runnerQueue('ec2', 'ec2');
50+
const instanceTypeLabel = 'ghr-ec2-instance-type:m7i.large';
51+
52+
expect(selectDynamicLabelQueue([microvmQueue, ec2Queue], ['self-hosted', 'linux'], [instanceTypeLabel])).toEqual({
53+
queue: ec2Queue,
54+
labels: ['self-hosted', 'linux', instanceTypeLabel],
55+
});
56+
});
3557
});
3658

3759
function runnerQueue(id: string, runnerProvider?: RunnerProviderType): RunnerMatcherConfig {

0 commit comments

Comments
 (0)