Skip to content

Commit f9c8232

Browse files
authored
refactor: split webhook accepting and queue dispatching logic (#4160)
## Description This PR refacting the webhook lambda. It splits the webhook logic in a part responsible to accept the event and a part to dispatch to the runner queues. Secondly it refactor and removes duplicate logic in tests. This change should allow to split the lambda in two lambda and and use the AWS EventBridge to accept events, and use the dispacher only for delegating an event to a runner queueu.
1 parent 612651b commit f9c8232

File tree

9 files changed

+453
-630
lines changed

9 files changed

+453
-630
lines changed

lambdas/functions/webhook/jest.config.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ const config: Config = {
66
...defaultConfig,
77
coverageThreshold: {
88
global: {
9-
statements: 99.13,
10-
branches: 96.87,
9+
statements: 99.2,
10+
branches: 100,
1111
functions: 100,
12-
lines: 99.09,
12+
lines: 99.25,
1313
},
1414
},
1515
};

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

+6-6
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { mocked } from 'jest-mock';
44

55
import { githubWebhook } from './lambda';
66
import { handle } from './webhook';
7-
import ValidationError from './ValidatonError';
7+
import ValidationError from './ValidationError';
88
import { getParameter } from '@aws-github-runner/aws-ssm-util';
99

1010
const event: APIGatewayEvent = {
@@ -85,28 +85,28 @@ describe('Test scale up lambda wrapper.', () => {
8585
const mock = mocked(handle);
8686
mock.mockImplementation(() => {
8787
return new Promise((resolve) => {
88-
resolve({ statusCode: 200 });
88+
resolve({ body: 'test', statusCode: 200 });
8989
});
9090
});
9191

9292
const result = await githubWebhook(event, context);
93-
expect(result).toEqual({ statusCode: 200 });
93+
expect(result).toEqual({ body: 'test', statusCode: 200 });
9494
});
9595

9696
it('An expected error, resolve.', async () => {
9797
const mock = mocked(handle);
9898
mock.mockRejectedValue(new ValidationError(400, 'some error'));
9999

100100
const result = await githubWebhook(event, context);
101-
expect(result).toMatchObject({ statusCode: 400 });
101+
expect(result).toMatchObject({ body: 'some error', statusCode: 400 });
102102
});
103103

104104
it('Errors are not thrown.', async () => {
105105
const mock = mocked(handle);
106106
const logSpy = jest.spyOn(logger, 'error');
107107
mock.mockRejectedValue(new Error('some error'));
108108
const result = await githubWebhook(event, context);
109-
expect(result).toMatchObject({ statusCode: 500 });
110-
expect(logSpy).toBeCalledTimes(1);
109+
expect(result).toMatchObject({ body: 'Check the Lambda logs for the error details.', statusCode: 500 });
110+
expect(logSpy).toHaveBeenCalledTimes(1);
111111
});
112112
});

lambdas/functions/webhook/src/lambda.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import { APIGatewayEvent, Context } from 'aws-lambda';
55
import { handle } from './webhook';
66
import { Config } from './ConfigResolver';
77
import { IncomingHttpHeaders } from 'http';
8-
import ValidationError from './ValidatonError';
8+
import ValidationError from './ValidationError';
99

1010
export interface Response {
1111
statusCode: number;
12-
body?: string;
12+
body: string;
1313
}
1414

1515
middy(githubWebhook).use(captureLambdaHandler(tracer));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import { getParameter } from '@aws-github-runner/aws-ssm-util';
2+
import { mocked } from 'jest-mock';
3+
import nock from 'nock';
4+
import { WorkflowJobEvent } from '@octokit/webhooks-types';
5+
6+
import workFlowJobEvent from '../../test/resources/github_workflowjob_event.json';
7+
import runnerConfig from '../../test/resources/multi_runner_configurations.json';
8+
9+
import { RunnerConfig, sendActionRequest } from '../sqs';
10+
import { canRunJob, dispatch } from './dispatch';
11+
import { Config } from '../ConfigResolver';
12+
13+
jest.mock('../sqs');
14+
jest.mock('@aws-github-runner/aws-ssm-util');
15+
16+
const sendWebhookEventToWorkflowJobQueueMock = jest.mocked(sendActionRequest);
17+
const GITHUB_APP_WEBHOOK_SECRET = 'TEST_SECRET';
18+
19+
const cleanEnv = process.env;
20+
21+
describe('Dispatcher', () => {
22+
let originalError: Console['error'];
23+
let config: Config;
24+
25+
beforeEach(async () => {
26+
process.env = { ...cleanEnv };
27+
28+
nock.disableNetConnect();
29+
originalError = console.error;
30+
console.error = jest.fn();
31+
jest.clearAllMocks();
32+
jest.resetAllMocks();
33+
34+
mockSSMResponse();
35+
config = await createConfig(undefined, runnerConfig);
36+
});
37+
38+
afterEach(() => {
39+
console.error = originalError;
40+
});
41+
42+
describe('handle work flow job events ', () => {
43+
it('should not handle "workflow_job" events with actions other than queued (action = started)', async () => {
44+
const event = { ...workFlowJobEvent, action: 'started' } as unknown as WorkflowJobEvent;
45+
const resp = await dispatch(event, 'workflow_job', config);
46+
expect(resp.statusCode).toBe(201);
47+
expect(sendActionRequest).not.toHaveBeenCalled();
48+
});
49+
50+
it('should not handle workflow_job events from unlisted repositories', async () => {
51+
const event = workFlowJobEvent as unknown as WorkflowJobEvent;
52+
config = await createConfig(['NotCodertocat/Hello-World']);
53+
await expect(dispatch(event, 'push', config)).rejects.toMatchObject({
54+
statusCode: 403,
55+
});
56+
expect(sendActionRequest).not.toHaveBeenCalled();
57+
expect(sendWebhookEventToWorkflowJobQueueMock).not.toHaveBeenCalled();
58+
});
59+
60+
it('should handle workflow_job events without installation id', async () => {
61+
config = await createConfig(['philips-labs/terraform-aws-github-runner']);
62+
const event = { ...workFlowJobEvent, installation: null } as unknown as WorkflowJobEvent;
63+
const resp = await dispatch(event, 'workflow_job', config);
64+
expect(resp.statusCode).toBe(201);
65+
expect(sendActionRequest).toHaveBeenCalled();
66+
expect(sendWebhookEventToWorkflowJobQueueMock).toHaveBeenCalled();
67+
});
68+
69+
it('should handle workflow_job events from allow listed repositories', async () => {
70+
config = await createConfig(['philips-labs/terraform-aws-github-runner']);
71+
const event = workFlowJobEvent as unknown as WorkflowJobEvent;
72+
const resp = await dispatch(event, 'workflow_job', config);
73+
expect(resp.statusCode).toBe(201);
74+
expect(sendActionRequest).toHaveBeenCalled();
75+
expect(sendWebhookEventToWorkflowJobQueueMock).toHaveBeenCalled();
76+
});
77+
78+
it('should match labels', async () => {
79+
config = await createConfig(undefined, [
80+
{
81+
...runnerConfig[0],
82+
matcherConfig: {
83+
labelMatchers: [['self-hosted', 'test']],
84+
exactMatch: true,
85+
},
86+
},
87+
runnerConfig[1],
88+
]);
89+
90+
const event = {
91+
...workFlowJobEvent,
92+
workflow_job: {
93+
...workFlowJobEvent.workflow_job,
94+
labels: ['self-hosted', 'Test'],
95+
},
96+
} as unknown as WorkflowJobEvent;
97+
const resp = await dispatch(event, 'workflow_job', config);
98+
expect(resp.statusCode).toBe(201);
99+
expect(sendActionRequest).toHaveBeenCalledWith({
100+
id: event.workflow_job.id,
101+
repositoryName: event.repository.name,
102+
repositoryOwner: event.repository.owner.login,
103+
eventType: 'workflow_job',
104+
installationId: 0,
105+
queueId: runnerConfig[0].id,
106+
queueFifo: false,
107+
repoOwnerType: 'Organization',
108+
});
109+
expect(sendWebhookEventToWorkflowJobQueueMock).toHaveBeenCalled();
110+
});
111+
112+
it('should sort matcher with exact first.', async () => {
113+
config = await createConfig(undefined, [
114+
{
115+
...runnerConfig[0],
116+
matcherConfig: {
117+
labelMatchers: [['self-hosted', 'match', 'not-select']],
118+
exactMatch: false,
119+
},
120+
},
121+
{
122+
...runnerConfig[0],
123+
matcherConfig: {
124+
labelMatchers: [['self-hosted', 'no-match']],
125+
exactMatch: true,
126+
},
127+
},
128+
{
129+
...runnerConfig[0],
130+
id: 'match',
131+
matcherConfig: {
132+
labelMatchers: [['self-hosted', 'match']],
133+
exactMatch: true,
134+
},
135+
},
136+
runnerConfig[1],
137+
]);
138+
139+
const event = {
140+
...workFlowJobEvent,
141+
workflow_job: {
142+
...workFlowJobEvent.workflow_job,
143+
labels: ['self-hosted', 'match'],
144+
},
145+
} as unknown as WorkflowJobEvent;
146+
const resp = await dispatch(event, 'workflow_job', config);
147+
expect(resp.statusCode).toBe(201);
148+
expect(sendActionRequest).toHaveBeenCalledWith({
149+
id: event.workflow_job.id,
150+
repositoryName: event.repository.name,
151+
repositoryOwner: event.repository.owner.login,
152+
eventType: 'workflow_job',
153+
installationId: 0,
154+
queueId: 'match',
155+
queueFifo: false,
156+
repoOwnerType: 'Organization',
157+
});
158+
expect(sendWebhookEventToWorkflowJobQueueMock).toHaveBeenCalled();
159+
});
160+
161+
it('should not accept jobs where not all labels are supported (single matcher).', async () => {
162+
config = await createConfig(undefined, [
163+
{
164+
...runnerConfig[0],
165+
matcherConfig: {
166+
labelMatchers: [['self-hosted', 'x64', 'linux']],
167+
exactMatch: true,
168+
},
169+
},
170+
]);
171+
172+
const event = {
173+
...workFlowJobEvent,
174+
workflow_job: {
175+
...workFlowJobEvent.workflow_job,
176+
labels: ['self-hosted', 'linux', 'x64', 'on-demand'],
177+
},
178+
} as unknown as WorkflowJobEvent;
179+
const resp = await dispatch(event, 'workflow_job', config);
180+
expect(resp.statusCode).toBe(202);
181+
expect(sendActionRequest).not.toHaveBeenCalled();
182+
expect(sendWebhookEventToWorkflowJobQueueMock).not.toHaveBeenCalled();
183+
});
184+
});
185+
186+
describe('decides can run job based on label and config (canRunJob)', () => {
187+
it('should accept job with an exact match and identical labels.', () => {
188+
const workflowLabels = ['self-hosted', 'linux', 'x64', 'ubuntu-latest'];
189+
const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']];
190+
expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(true);
191+
});
192+
193+
it('should accept job with an exact match and identical labels, ignoring cases.', () => {
194+
const workflowLabels = ['self-Hosted', 'Linux', 'X64', 'ubuntu-Latest'];
195+
const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']];
196+
expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(true);
197+
});
198+
199+
it('should accept job with an exact match and runner supports requested capabilities.', () => {
200+
const workflowLabels = ['self-hosted', 'linux', 'x64'];
201+
const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']];
202+
expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(true);
203+
});
204+
205+
it('should NOT accept job with an exact match and runner not matching requested capabilities.', () => {
206+
const workflowLabels = ['self-hosted', 'linux', 'x64', 'ubuntu-latest'];
207+
const runnerLabels = [['self-hosted', 'linux', 'x64']];
208+
expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(false);
209+
});
210+
211+
it('should accept job with for a non exact match. Any label that matches will accept the job.', () => {
212+
const workflowLabels = ['self-hosted', 'linux', 'x64', 'ubuntu-latest', 'gpu'];
213+
const runnerLabels = [['gpu']];
214+
expect(canRunJob(workflowLabels, runnerLabels, false)).toBe(true);
215+
});
216+
217+
it('should NOT accept job with for an exact match. Not all requested capabilites are supported.', () => {
218+
const workflowLabels = ['self-hosted', 'linux', 'x64', 'ubuntu-latest', 'gpu'];
219+
const runnerLabels = [['gpu']];
220+
expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(false);
221+
});
222+
223+
it('should not accept jobs not providing labels if exact match is.', () => {
224+
const workflowLabels: string[] = [];
225+
const runnerLabels = [['self-hosted', 'linux', 'x64']];
226+
expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(false);
227+
});
228+
229+
it('should accept jobs not providing labels and exact match is set to false.', () => {
230+
const workflowLabels: string[] = [];
231+
const runnerLabels = [['self-hosted', 'linux', 'x64']];
232+
expect(canRunJob(workflowLabels, runnerLabels, false)).toBe(true);
233+
});
234+
});
235+
});
236+
237+
function mockSSMResponse(runnerConfigInput?: RunnerConfig) {
238+
const mockedGet = mocked(getParameter);
239+
mockedGet.mockImplementation((parameter_name) => {
240+
const value =
241+
parameter_name == '/github-runner/runner-matcher-config'
242+
? JSON.stringify(runnerConfigInput ?? runnerConfig)
243+
: GITHUB_APP_WEBHOOK_SECRET;
244+
return Promise.resolve(value);
245+
});
246+
}
247+
248+
async function createConfig(repositoryAllowList?: string[], runnerConfig?: RunnerConfig): Promise<Config> {
249+
if (repositoryAllowList) {
250+
process.env.REPOSITORY_ALLOW_LIST = JSON.stringify(repositoryAllowList);
251+
}
252+
Config.reset();
253+
mockSSMResponse(runnerConfig);
254+
return await Config.load();
255+
}

0 commit comments

Comments
 (0)