diff --git a/lambdas/functions/webhook/jest.config.ts b/lambdas/functions/webhook/jest.config.ts index 6a96a1fe17..8511e3baa5 100644 --- a/lambdas/functions/webhook/jest.config.ts +++ b/lambdas/functions/webhook/jest.config.ts @@ -6,10 +6,10 @@ const config: Config = { ...defaultConfig, coverageThreshold: { global: { - statements: 99.13, - branches: 96.87, + statements: 99.2, + branches: 100, functions: 100, - lines: 99.09, + lines: 99.25, }, }, }; diff --git a/lambdas/functions/webhook/src/ValidatonError.ts b/lambdas/functions/webhook/src/ValidationError.ts similarity index 100% rename from lambdas/functions/webhook/src/ValidatonError.ts rename to lambdas/functions/webhook/src/ValidationError.ts diff --git a/lambdas/functions/webhook/src/lambda.test.ts b/lambdas/functions/webhook/src/lambda.test.ts index cb6ec81113..599c6594db 100644 --- a/lambdas/functions/webhook/src/lambda.test.ts +++ b/lambdas/functions/webhook/src/lambda.test.ts @@ -4,7 +4,7 @@ import { mocked } from 'jest-mock'; import { githubWebhook } from './lambda'; import { handle } from './webhook'; -import ValidationError from './ValidatonError'; +import ValidationError from './ValidationError'; import { getParameter } from '@aws-github-runner/aws-ssm-util'; const event: APIGatewayEvent = { @@ -85,12 +85,12 @@ describe('Test scale up lambda wrapper.', () => { const mock = mocked(handle); mock.mockImplementation(() => { return new Promise((resolve) => { - resolve({ statusCode: 200 }); + resolve({ body: 'test', statusCode: 200 }); }); }); const result = await githubWebhook(event, context); - expect(result).toEqual({ statusCode: 200 }); + expect(result).toEqual({ body: 'test', statusCode: 200 }); }); it('An expected error, resolve.', async () => { @@ -98,7 +98,7 @@ describe('Test scale up lambda wrapper.', () => { mock.mockRejectedValue(new ValidationError(400, 'some error')); const result = await githubWebhook(event, context); - expect(result).toMatchObject({ statusCode: 400 }); + expect(result).toMatchObject({ body: 'some error', statusCode: 400 }); }); it('Errors are not thrown.', async () => { @@ -106,7 +106,7 @@ describe('Test scale up lambda wrapper.', () => { const logSpy = jest.spyOn(logger, 'error'); mock.mockRejectedValue(new Error('some error')); const result = await githubWebhook(event, context); - expect(result).toMatchObject({ statusCode: 500 }); - expect(logSpy).toBeCalledTimes(1); + expect(result).toMatchObject({ body: 'Check the Lambda logs for the error details.', statusCode: 500 }); + expect(logSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/lambdas/functions/webhook/src/lambda.ts b/lambdas/functions/webhook/src/lambda.ts index 88a3a470d5..df064604fb 100644 --- a/lambdas/functions/webhook/src/lambda.ts +++ b/lambdas/functions/webhook/src/lambda.ts @@ -5,11 +5,11 @@ import { APIGatewayEvent, Context } from 'aws-lambda'; import { handle } from './webhook'; import { Config } from './ConfigResolver'; import { IncomingHttpHeaders } from 'http'; -import ValidationError from './ValidatonError'; +import ValidationError from './ValidationError'; export interface Response { statusCode: number; - body?: string; + body: string; } middy(githubWebhook).use(captureLambdaHandler(tracer)); diff --git a/lambdas/functions/webhook/src/runners/dispatch.test.ts b/lambdas/functions/webhook/src/runners/dispatch.test.ts new file mode 100644 index 0000000000..4a003c594b --- /dev/null +++ b/lambdas/functions/webhook/src/runners/dispatch.test.ts @@ -0,0 +1,255 @@ +import { getParameter } from '@aws-github-runner/aws-ssm-util'; +import { mocked } from 'jest-mock'; +import nock from 'nock'; +import { WorkflowJobEvent } from '@octokit/webhooks-types'; + +import workFlowJobEvent from '../../test/resources/github_workflowjob_event.json'; +import runnerConfig from '../../test/resources/multi_runner_configurations.json'; + +import { RunnerConfig, sendActionRequest } from '../sqs'; +import { canRunJob, dispatch } from './dispatch'; +import { Config } from '../ConfigResolver'; + +jest.mock('../sqs'); +jest.mock('@aws-github-runner/aws-ssm-util'); + +const sendWebhookEventToWorkflowJobQueueMock = jest.mocked(sendActionRequest); +const GITHUB_APP_WEBHOOK_SECRET = 'TEST_SECRET'; + +const cleanEnv = process.env; + +describe('Dispatcher', () => { + let originalError: Console['error']; + let config: Config; + + beforeEach(async () => { + process.env = { ...cleanEnv }; + + nock.disableNetConnect(); + originalError = console.error; + console.error = jest.fn(); + jest.clearAllMocks(); + jest.resetAllMocks(); + + mockSSMResponse(); + config = await createConfig(undefined, runnerConfig); + }); + + afterEach(() => { + console.error = originalError; + }); + + describe('handle work flow job events ', () => { + it('should not handle "workflow_job" events with actions other than queued (action = started)', async () => { + const event = { ...workFlowJobEvent, action: 'started' } as unknown as WorkflowJobEvent; + const resp = await dispatch(event, 'workflow_job', config); + expect(resp.statusCode).toBe(201); + expect(sendActionRequest).not.toHaveBeenCalled(); + }); + + it('should not handle workflow_job events from unlisted repositories', async () => { + const event = workFlowJobEvent as unknown as WorkflowJobEvent; + config = await createConfig(['NotCodertocat/Hello-World']); + await expect(dispatch(event, 'push', config)).rejects.toMatchObject({ + statusCode: 403, + }); + expect(sendActionRequest).not.toHaveBeenCalled(); + expect(sendWebhookEventToWorkflowJobQueueMock).not.toHaveBeenCalled(); + }); + + it('should handle workflow_job events without installation id', async () => { + config = await createConfig(['philips-labs/terraform-aws-github-runner']); + const event = { ...workFlowJobEvent, installation: null } as unknown as WorkflowJobEvent; + const resp = await dispatch(event, 'workflow_job', config); + expect(resp.statusCode).toBe(201); + expect(sendActionRequest).toHaveBeenCalled(); + expect(sendWebhookEventToWorkflowJobQueueMock).toHaveBeenCalled(); + }); + + it('should handle workflow_job events from allow listed repositories', async () => { + config = await createConfig(['philips-labs/terraform-aws-github-runner']); + const event = workFlowJobEvent as unknown as WorkflowJobEvent; + const resp = await dispatch(event, 'workflow_job', config); + expect(resp.statusCode).toBe(201); + expect(sendActionRequest).toHaveBeenCalled(); + expect(sendWebhookEventToWorkflowJobQueueMock).toHaveBeenCalled(); + }); + + it('should match labels', async () => { + config = await createConfig(undefined, [ + { + ...runnerConfig[0], + matcherConfig: { + labelMatchers: [['self-hosted', 'test']], + exactMatch: true, + }, + }, + runnerConfig[1], + ]); + + const event = { + ...workFlowJobEvent, + workflow_job: { + ...workFlowJobEvent.workflow_job, + labels: ['self-hosted', 'Test'], + }, + } as unknown as WorkflowJobEvent; + const resp = await dispatch(event, 'workflow_job', config); + expect(resp.statusCode).toBe(201); + expect(sendActionRequest).toHaveBeenCalledWith({ + id: event.workflow_job.id, + repositoryName: event.repository.name, + repositoryOwner: event.repository.owner.login, + eventType: 'workflow_job', + installationId: 0, + queueId: runnerConfig[0].id, + queueFifo: false, + repoOwnerType: 'Organization', + }); + expect(sendWebhookEventToWorkflowJobQueueMock).toHaveBeenCalled(); + }); + + it('should sort matcher with exact first.', async () => { + config = await createConfig(undefined, [ + { + ...runnerConfig[0], + matcherConfig: { + labelMatchers: [['self-hosted', 'match', 'not-select']], + exactMatch: false, + }, + }, + { + ...runnerConfig[0], + matcherConfig: { + labelMatchers: [['self-hosted', 'no-match']], + exactMatch: true, + }, + }, + { + ...runnerConfig[0], + id: 'match', + matcherConfig: { + labelMatchers: [['self-hosted', 'match']], + exactMatch: true, + }, + }, + runnerConfig[1], + ]); + + const event = { + ...workFlowJobEvent, + workflow_job: { + ...workFlowJobEvent.workflow_job, + labels: ['self-hosted', 'match'], + }, + } as unknown as WorkflowJobEvent; + const resp = await dispatch(event, 'workflow_job', config); + expect(resp.statusCode).toBe(201); + expect(sendActionRequest).toHaveBeenCalledWith({ + id: event.workflow_job.id, + repositoryName: event.repository.name, + repositoryOwner: event.repository.owner.login, + eventType: 'workflow_job', + installationId: 0, + queueId: 'match', + queueFifo: false, + repoOwnerType: 'Organization', + }); + expect(sendWebhookEventToWorkflowJobQueueMock).toHaveBeenCalled(); + }); + + it('should not accept jobs where not all labels are supported (single matcher).', async () => { + config = await createConfig(undefined, [ + { + ...runnerConfig[0], + matcherConfig: { + labelMatchers: [['self-hosted', 'x64', 'linux']], + exactMatch: true, + }, + }, + ]); + + const event = { + ...workFlowJobEvent, + workflow_job: { + ...workFlowJobEvent.workflow_job, + labels: ['self-hosted', 'linux', 'x64', 'on-demand'], + }, + } as unknown as WorkflowJobEvent; + const resp = await dispatch(event, 'workflow_job', config); + expect(resp.statusCode).toBe(202); + expect(sendActionRequest).not.toHaveBeenCalled(); + expect(sendWebhookEventToWorkflowJobQueueMock).not.toHaveBeenCalled(); + }); + }); + + describe('decides can run job based on label and config (canRunJob)', () => { + 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)).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)).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)).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)).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)).toBe(true); + }); + + it('should NOT accept job with for an exact match. Not all requested capabilites are supported.', () => { + const workflowLabels = ['self-hosted', 'linux', 'x64', 'ubuntu-latest', 'gpu']; + const runnerLabels = [['gpu']]; + expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(false); + }); + + it('should not accept jobs not providing labels if exact match is.', () => { + const workflowLabels: string[] = []; + const runnerLabels = [['self-hosted', 'linux', 'x64']]; + expect(canRunJob(workflowLabels, runnerLabels, true)).toBe(false); + }); + + it('should accept jobs not providing labels and exact match is set to false.', () => { + const workflowLabels: string[] = []; + const runnerLabels = [['self-hosted', 'linux', 'x64']]; + expect(canRunJob(workflowLabels, runnerLabels, false)).toBe(true); + }); + }); +}); + +function mockSSMResponse(runnerConfigInput?: RunnerConfig) { + const mockedGet = mocked(getParameter); + mockedGet.mockImplementation((parameter_name) => { + const value = + parameter_name == '/github-runner/runner-matcher-config' + ? JSON.stringify(runnerConfigInput ?? runnerConfig) + : GITHUB_APP_WEBHOOK_SECRET; + return Promise.resolve(value); + }); +} + +async function createConfig(repositoryAllowList?: string[], runnerConfig?: RunnerConfig): Promise { + if (repositoryAllowList) { + process.env.REPOSITORY_ALLOW_LIST = JSON.stringify(repositoryAllowList); + } + Config.reset(); + mockSSMResponse(runnerConfig); + return await Config.load(); +} diff --git a/lambdas/functions/webhook/src/runners/dispatch.ts b/lambdas/functions/webhook/src/runners/dispatch.ts new file mode 100644 index 0000000000..52985e7bf3 --- /dev/null +++ b/lambdas/functions/webhook/src/runners/dispatch.ts @@ -0,0 +1,87 @@ +import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; +import { WorkflowJobEvent } from '@octokit/webhooks-types'; + +import { Response } from '../lambda'; +import { RunnerMatcherConfig, sendActionRequest, sendWebhookEventToWorkflowJobQueue } from '../sqs'; +import { Config } from '../ConfigResolver'; +import ValidationError from '../ValidationError'; + +const logger = createChildLogger('handler'); + +export async function dispatch(event: WorkflowJobEvent, eventType: string, config: Config): Promise { + validateRepoInAllowList(event, config); + + const result = await handleWorkflowJob(event, eventType, Config.matcherConfig!); + await sendWebhookEventToWorkflowJobQueue({ workflowJobEvent: event }, config); + + return result; +} + +function validateRepoInAllowList(event: WorkflowJobEvent, config: Config) { + if (config.repositoryAllowList.length > 0 && !config.repositoryAllowList.includes(event.repository.full_name)) { + logger.info(`Received event from unauthorized repository ${event.repository.full_name}`); + throw new ValidationError(403, `Received event from unauthorized repository ${event.repository.full_name}`); + } +} + +async function handleWorkflowJob( + body: WorkflowJobEvent, + githubEvent: string, + matcherConfig: Array, +): Promise { + if (body.action === 'queued') { + // sort the queuesConfig by order of matcher config exact match, with all true matches lined up ahead. + 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)) { + 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, + queueFifo: queue.fifo, + repoOwnerType: body.repository.owner.type, + }); + logger.info(`Successfully dispatched job for ${body.repository.full_name} to the queue ${queue.id}`); + return { + statusCode: 201, + body: `Successfully queued job for ${body.repository.full_name} to the queue ${queue.id}`, + }; + } + } + logger.warn(`Received event contains runner labels '${body.workflow_job.labels}' that are not accepted.`); + return { + statusCode: 202, + body: `Received event contains runner labels '${body.workflow_job.labels}' that are not accepted.`, + }; + } + return { + statusCode: 201, + body: `Received not queued and will not be ignored.`, + }; +} + +export function canRunJob( + workflowJobLabels: string[], + runnerLabelsMatchers: string[][], + workflowLabelCheckAll: boolean, +): boolean { + runnerLabelsMatchers = runnerLabelsMatchers.map((runnerLabel) => { + return runnerLabel.map((label) => label.toLowerCase()); + }); + const matchLabels = workflowLabelCheckAll + ? runnerLabelsMatchers.some((rl) => workflowJobLabels.every((wl) => rl.includes(wl.toLowerCase()))) + : runnerLabelsMatchers.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(',')}'`, + ); + return match; +} diff --git a/lambdas/functions/webhook/src/webhook/index.test.ts b/lambdas/functions/webhook/src/webhook/index.test.ts index 4688efc96e..9144d23b51 100644 --- a/lambdas/functions/webhook/src/webhook/index.test.ts +++ b/lambdas/functions/webhook/src/webhook/index.test.ts @@ -3,15 +3,17 @@ import { getParameter } from '@aws-github-runner/aws-ssm-util'; import { mocked } from 'jest-mock'; import nock from 'nock'; -import checkrun_event from '../../test/resources/github_check_run_event.json'; -import workflowjob_event from '../../test/resources/github_workflowjob_event.json'; +import workFlowJobEvent from '../../test/resources/github_workflowjob_event.json'; import runnerConfig from '../../test/resources/multi_runner_configurations.json'; -import { RunnerConfig, sendActionRequest } from '../sqs'; -import { canRunJob, handle } from '.'; +import { RunnerConfig } from '../sqs'; +import { checkBodySize, handle } from '.'; import { Config } from '../ConfigResolver'; +import { dispatch } from '../runners/dispatch'; +import { IncomingHttpHeaders } from 'http'; jest.mock('../sqs'); +jest.mock('../runners/dispatch'); jest.mock('@aws-github-runner/aws-ssm-util'); const GITHUB_APP_WEBHOOK_SECRET = 'TEST_SECRET'; @@ -22,18 +24,7 @@ const webhooks = new Webhooks({ secret: 'TEST_SECRET', }); -const mockSQS = { - sendMessage: jest.fn(() => { - { - return { promise: jest.fn() }; - } - }), -}; -jest.mock('aws-sdk', () => ({ - SQS: jest.fn().mockImplementation(() => mockSQS), -})); - -describe('handler', () => { +describe('handle GitHub webhook events', () => { let originalError: Console['error']; let config: Config; @@ -54,561 +45,98 @@ describe('handler', () => { console.error = originalError; }); - it('returns 500 if no signature available', async () => { + it('should return 500 if no signature available', async () => { await expect(handle({}, '', config)).rejects.toMatchObject({ statusCode: 500, }); }); - it('returns 403 if invalid signature', async () => { - const event = JSON.stringify(workflowjob_event); - const other = JSON.stringify({ ...workflowjob_event, action: 'mutated' }); - - await expect( - handle({ 'X-Hub-Signature-256': await webhooks.sign(other), 'X-GitHub-Event': 'workflow_job' }, event, config), - ).rejects.toMatchObject({ - statusCode: 401, - }); - }); - - describe('Test for workflowjob event: ', () => { - beforeEach(async () => { - config = await createConfig(undefined, runnerConfig); - }); - - it('handles workflow job events with 256 hash signature', async () => { - const event = JSON.stringify(workflowjob_event); - const resp = await handle( - { 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'workflow_job' }, - event, - config, - ); - expect(resp.statusCode).toBe(201); - expect(sendActionRequest).toHaveBeenCalled(); - }); - - it('does not handle other events', async () => { - const event = JSON.stringify(workflowjob_event); - await expect( - handle({ 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'push' }, event, config), - ).rejects.toMatchObject({ - statusCode: 202, - }); - expect(sendActionRequest).not.toHaveBeenCalled(); - }); - - it('does not handle workflow_job events with actions other than queued (action = started)', async () => { - const event = JSON.stringify({ ...workflowjob_event, action: 'started' }); - const resp = await handle( - { 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'workflow_job' }, - event, - config, - ); - expect(resp.statusCode).toBe(201); - expect(sendActionRequest).not.toHaveBeenCalled(); - }); - - it('does not handle workflow_job events with actions other than queued (action = completed)', async () => { - const event = JSON.stringify({ ...workflowjob_event, action: 'completed' }); - const resp = await handle( - { 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'workflow_job' }, - event, - config, - ); - expect(resp.statusCode).toBe(201); - expect(sendActionRequest).not.toHaveBeenCalled(); - }); - - it('does not handle workflow_job events from unlisted repositories', async () => { - const event = JSON.stringify(workflowjob_event); - config = await createConfig(['NotCodertocat/Hello-World']); - await expect( - handle({ 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'workflow_job' }, event, config), - ).rejects.toMatchObject({ - statusCode: 403, - }); - expect(sendActionRequest).not.toHaveBeenCalled(); - }); - - it('handles workflow_job events without installation id', async () => { - const event = JSON.stringify({ ...workflowjob_event, installation: null }); - config = await createConfig(['philips-labs/terraform-aws-github-runner']); - const resp = await handle( - { 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'workflow_job' }, - event, - config, - ); - expect(resp.statusCode).toBe(201); - expect(sendActionRequest).toBeCalled(); - }); - - it('handles workflow_job events from allow listed repositories', async () => { - const event = JSON.stringify(workflowjob_event); - config = await createConfig(['philips-labs/terraform-aws-github-runner']); - const resp = await handle( - { 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'workflow_job' }, - event, - config, - ); - expect(resp.statusCode).toBe(201); - expect(sendActionRequest).toBeCalled(); - }); - - it('Check runner labels accept test job', async () => { - config = await createConfig(undefined, [ - { - ...runnerConfig[0], - matcherConfig: { - labelMatchers: [['self-hosted', 'test']], - exactMatch: true, - }, - }, - { - ...runnerConfig[1], - matcherConfig: { - labelMatchers: [['self-hosted', 'test1']], - exactMatch: true, - }, - }, - ]); - const event = JSON.stringify({ - ...workflowjob_event, - workflow_job: { - ...workflowjob_event.workflow_job, - labels: ['self-hosted', 'Test'], - }, - }); - const resp = await handle( - { 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'workflow_job' }, - event, - config, - ); - expect(resp.statusCode).toBe(201); - expect(sendActionRequest).toBeCalled(); - }); - - it('Check runner labels accept job with mixed order.', async () => { - config = await createConfig(undefined, [ - { - ...runnerConfig[0], - matcherConfig: { - labelMatchers: [['linux', 'TEST', 'self-hosted']], - exactMatch: true, - }, - }, - { - ...runnerConfig[1], - matcherConfig: { - labelMatchers: [['self-hosted', 'test1']], - exactMatch: true, - }, - }, - ]); - const event = JSON.stringify({ - ...workflowjob_event, - workflow_job: { - ...workflowjob_event.workflow_job, - labels: ['linux', 'self-hosted', 'test'], - }, - }); - const resp = await handle( - { 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'workflow_job' }, - event, - config, - ); - expect(resp.statusCode).toBe(201); - expect(sendActionRequest).toBeCalled(); - }); - - it('Check webhook accept jobs where not all labels are provided in job.', async () => { - config = await createConfig(undefined, [ - { - ...runnerConfig[0], - matcherConfig: { - labelMatchers: [['self-hosted', 'test', 'test2']], - exactMatch: true, - }, - }, - { - ...runnerConfig[1], - matcherConfig: { - labelMatchers: [['self-hosted', 'test1']], - exactMatch: true, - }, - }, - ]); - const event = JSON.stringify({ - ...workflowjob_event, - workflow_job: { - ...workflowjob_event.workflow_job, - labels: ['self-hosted'], - }, - }); - const resp = await handle( - { 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'workflow_job' }, - event, - config, - ); - expect(resp.statusCode).toBe(201); - expect(sendActionRequest).toBeCalled(); - }); - - it('Check webhook does not accept jobs where not all labels are supported (single matcher).', async () => { - config = await createConfig(undefined, [ - { - ...runnerConfig[0], - matcherConfig: { - labelMatchers: [['self-hosted', 'x64', 'linux']], - exactMatch: true, - }, - }, - ]); - - const event = JSON.stringify({ - ...workflowjob_event, - workflow_job: { - ...workflowjob_event.workflow_job, - labels: ['self-hosted', 'linux', 'x64', 'on-demand'], - }, - }); - const resp = await handle( - { 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'workflow_job' }, - event, - config, - ); - expect(resp.statusCode).toBe(202); - expect(sendActionRequest).not.toBeCalled(); + it('should accept large events', async () => { + // setup + mocked(dispatch).mockImplementation(() => { + return Promise.resolve({ body: 'test', statusCode: 201 }); }); - it('Check webhook does not accept jobs where the job labels are spread across label matchers.', async () => { - config = await createConfig(undefined, [ - { - ...runnerConfig[0], - matcherConfig: { - labelMatchers: [ - ['self-hosted', 'x64', 'linux'], - ['self-hosted', 'x64', 'on-demand'], - ], - exactMatch: true, - }, - }, - ]); + const event = JSON.stringify(workFlowJobEvent); - const event = JSON.stringify({ - ...workflowjob_event, - workflow_job: { - ...workflowjob_event.workflow_job, - labels: ['self-hosted', 'linux', 'x64', 'on-demand'], - }, - }); - const resp = await handle( - { 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'workflow_job' }, - event, - config, - ); - expect(resp.statusCode).toBe(202); - expect(sendActionRequest).not.toBeCalled(); - }); - - it('Check webhook does not accept jobs where not all labels are supported by the runner.', async () => { - config = await createConfig(undefined, [ - { - ...runnerConfig[0], - matcherConfig: { - labelMatchers: [['self-hosted', 'x64', 'linux', 'test']], - exactMatch: true, - }, - }, - { - ...runnerConfig[1], - matcherConfig: { - labelMatchers: [['self-hosted', 'x64', 'linux', 'test1']], - exactMatch: true, - }, - }, - ]); - - const event = JSON.stringify({ - ...workflowjob_event, - workflow_job: { - ...workflowjob_event.workflow_job, - labels: ['self-hosted', 'linux', 'x64', 'test', 'gpu'], - }, - }); - const resp = await handle( - { 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'workflow_job' }, - event, - config, - ); - expect(resp.statusCode).toBe(202); - expect(sendActionRequest).not.toBeCalled(); - }); - - it('Check webhook will accept jobs with a single acceptable label.', async () => { - config = await createConfig(undefined, [ - { - ...runnerConfig[0], - matcherConfig: { - labelMatchers: [['self-hosted', 'test', 'test2']], - exactMatch: true, - }, - }, - { - ...runnerConfig[1], - matcherConfig: { - labelMatchers: [['self-hosted', 'x64']], - exactMatch: false, - }, - }, - ]); - - const event = JSON.stringify({ - ...workflowjob_event, - workflow_job: { - ...workflowjob_event.workflow_job, - labels: ['x64'], - }, - }); - const resp = await handle( - { 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'workflow_job' }, - event, - config, - ); - expect(resp.statusCode).toBe(201); - expect(sendActionRequest).toBeCalled(); - }); - - it('Check webhook will not accept jobs without correct label when job label check all is false.', async () => { - config = await createConfig(undefined, [ - { - ...runnerConfig[0], - matcherConfig: { - labelMatchers: [['self-hosted', 'x64', 'linux', 'test']], - exactMatch: false, - }, - }, - { - ...runnerConfig[1], - matcherConfig: { - labelMatchers: [['self-hosted', 'x64', 'linux', 'test1']], - exactMatch: false, - }, - }, - ]); - const event = JSON.stringify({ - ...workflowjob_event, - workflow_job: { - ...workflowjob_event.workflow_job, - labels: ['ubuntu-latest'], - }, - }); - const resp = await handle( - { 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'workflow_job' }, - event, - config, - ); - expect(resp.statusCode).toBe(202); - expect(sendActionRequest).not.toBeCalled(); - }); - it('Check webhook will accept jobs for specific labels if workflow labels are specific', async () => { - config = await createConfig(undefined, [ - { - ...runnerConfig[0], - matcherConfig: { - labelMatchers: [['self-hosted']], - exactMatch: false, - }, - id: 'ubuntu-queue-id', - }, - { - ...runnerConfig[1], - matcherConfig: { - labelMatchers: [['self-hosted']], - exactMatch: false, - }, - id: 'default-queue-id', - }, - ]); - const event = JSON.stringify({ - ...workflowjob_event, - workflow_job: { - ...workflowjob_event.workflow_job, - labels: ['self-hosted', 'ubuntu', 'x64', 'linux'], - }, - }); - const resp = await handle( - { 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'workflow_job' }, - event, - config, - ); - expect(resp.statusCode).toBe(201); - expect(sendActionRequest).toBeCalledWith({ - id: workflowjob_event.workflow_job.id, - repositoryName: workflowjob_event.repository.name, - repositoryOwner: workflowjob_event.repository.owner.login, - eventType: 'workflow_job', - installationId: 0, - queueId: 'ubuntu-queue-id', - queueFifo: false, - repoOwnerType: 'Organization', - }); - }); - it('Check webhook will accept jobs for latest labels if workflow labels are not specific', async () => { - config = await createConfig(undefined, [ - { - ...runnerConfig[0], - matcherConfig: { - labelMatchers: [['self-hosted']], - exactMatch: false, - }, - id: 'ubuntu-queue-id', - }, - { - ...runnerConfig[1], - matcherConfig: { - labelMatchers: [['self-hosted']], - exactMatch: false, - }, - id: 'default-queue-id', - }, - ]); - const event = JSON.stringify({ - ...workflowjob_event, - workflow_job: { - ...workflowjob_event.workflow_job, - labels: ['self-hosted', 'linux', 'x64'], - }, - }); - const resp = await handle( - { 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'workflow_job' }, - event, - config, - ); - expect(resp.statusCode).toBe(201); - expect(sendActionRequest).toBeCalledWith({ - id: workflowjob_event.workflow_job.id, - repositoryName: workflowjob_event.repository.name, - repositoryOwner: workflowjob_event.repository.owner.login, - eventType: 'workflow_job', - installationId: 0, - queueId: 'ubuntu-queue-id', - queueFifo: false, - repoOwnerType: 'Organization', - }); - }); - }); - - it('Check webhook will accept jobs when matchers accepts multiple labels.', async () => { - config = await createConfig(undefined, [ + // act and assert + const result = handle( { - ...runnerConfig[0], - matcherConfig: { - labelMatchers: [ - ['self-hosted', 'arm64', 'linux', 'ubuntu-latest'], - ['self-hosted', 'arm64', 'linux', 'ubuntu-2204'], - ], - exactMatch: false, - }, - id: 'ubuntu-queue-id', - }, - ]); - const event = JSON.stringify({ - ...workflowjob_event, - workflow_job: { - ...workflowjob_event.workflow_job, - labels: ['self-hosted', 'linux', 'arm64', 'ubuntu-latest'], + 'X-Hub-Signature-256': await webhooks.sign(event), + 'X-GitHub-Event': 'workflow_job', + 'content-length': (1024 * 256 + 1).toString(), }, - }); - const resp = await handle( - { 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'workflow_job' }, event, config, ); - expect(resp.statusCode).toBe(201); - expect(sendActionRequest).toBeCalledWith({ - id: workflowjob_event.workflow_job.id, - repositoryName: workflowjob_event.repository.name, - repositoryOwner: workflowjob_event.repository.owner.login, - eventType: 'workflow_job', - installationId: 0, - queueId: 'ubuntu-queue-id', - queueFifo: false, - repoOwnerType: 'Organization', + expect(result).resolves.toMatchObject({ + statusCode: 201, }); }); - describe('Test for check_run is ignored.', () => { - it('handles check_run events', async () => { - const event = JSON.stringify(checkrun_event); - await expect( - handle({ 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'check_run' }, event, config), - ).rejects.toMatchObject({ - statusCode: 202, - }); - expect(sendActionRequest).not.toHaveBeenCalled(); + it('should reject with 403 if invalid signature', async () => { + const event = JSON.stringify(workFlowJobEvent); + const other = JSON.stringify({ ...workFlowJobEvent, action: 'mutated' }); + + await expect( + handle({ 'X-Hub-Signature-256': await webhooks.sign(other), 'X-GitHub-Event': 'workflow_job' }, event, config), + ).rejects.toMatchObject({ + statusCode: 401, }); }); -}); -describe('canRunJob', () => { - 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']]; - const exactMatch = true; - expect(canRunJob(workflowLabels, runnerLabels, exactMatch)).toBe(true); - }); + it('should reject with 202 if event type is not supported', async () => { + const event = JSON.stringify(workFlowJobEvent); - it('should accept job with an exact match and runner supports requested capabilites.', () => { - const workflowLabels = ['self-hosted', 'linux', 'x64']; - const runnerLabels = [['self-hosted', 'linux', 'x64', 'ubuntu-latest']]; - const exactMatch = true; - expect(canRunJob(workflowLabels, runnerLabels, exactMatch)).toBe(true); + await expect( + handle({ 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'invalid' }, event, config), + ).rejects.toMatchObject({ + statusCode: 202, + }); }); - it('should NOT accept job with an exact match and runner not matching requested capabilites.', () => { - const workflowLabels = ['self-hosted', 'linux', 'x64', 'ubuntu-latest']; - const runnerLabels = [['self-hosted', 'linux', 'x64']]; - const exactMatch = true; - expect(canRunJob(workflowLabels, runnerLabels, exactMatch)).toBe(false); - }); + it('should accept with 201 if valid signature', async () => { + const event = JSON.stringify(workFlowJobEvent); + + mocked(dispatch).mockImplementation(() => { + return Promise.resolve({ body: 'test', statusCode: 201 }); + }); - 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']]; - const exactMatch = false; - expect(canRunJob(workflowLabels, runnerLabels, exactMatch)).toBe(true); + await expect( + handle({ 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'workflow_job' }, event, config), + ).resolves.toMatchObject({ + statusCode: 201, + }); }); +}); - it('should NOT accept job with for an exact match. Not all requested capabilites are supported.', () => { - const workflowLabels = ['self-hosted', 'linux', 'x64', 'ubuntu-latest', 'gpu']; - const runnerLabels = [['gpu']]; - const exactMatch = true; - expect(canRunJob(workflowLabels, runnerLabels, exactMatch)).toBe(false); +describe('Check message size (checkBodySize)', () => { + it('should return sizeExceeded if body is to big', () => { + const body = JSON.stringify({ a: 'a'.repeat(1024 * 256) }); + const headers: IncomingHttpHeaders = { + 'content-length': Buffer.byteLength(body).toString(), + }; + const result = checkBodySize(body, headers); + expect(result.sizeExceeded).toBe(true); }); - it('Should not accecpt jobs not providing labels if exact match is.', () => { - const workflowLabels: string[] = []; - const runnerLabels = [['self-hosted', 'linux', 'x64']]; - const exactMatch = true; - expect(canRunJob(workflowLabels, runnerLabels, exactMatch)).toBe(false); + it('should return sizeExceeded if body is to big and content-length is not available', () => { + const body = JSON.stringify({ a: 'a'.repeat(1024 * 256) }); + const headers: IncomingHttpHeaders = {}; + const result = checkBodySize(body, headers); + expect(result.sizeExceeded).toBe(true); }); - it('Should accept jobs not providing labels and exact match is set to false.', () => { - const workflowLabels: string[] = []; - const runnerLabels = [['self-hosted', 'linux', 'x64']]; - const exactMatch = false; - expect(canRunJob(workflowLabels, runnerLabels, exactMatch)).toBe(true); + it('should return sizeExceeded if body is to big and content-length is not a number', () => { + const body = JSON.stringify({ a: 'a'.repeat(1024 * 256) }); + const headers: IncomingHttpHeaders = { + 'content-length': 'NaN', + }; + const result = checkBodySize(body, headers); + expect(result.sizeExceeded).toBe(true); }); }); -async function createConfig(repositoryAllowList?: string[], runnerConfig?: RunnerConfig): Promise { - if (repositoryAllowList) { - process.env.REPOSITORY_ALLOW_LIST = JSON.stringify(repositoryAllowList); - } - Config.reset(); - mockSSMResponse(runnerConfig); - return await Config.load(); -} function mockSSMResponse(runnerConfigInput?: RunnerConfig) { const mockedGet = mocked(getParameter); mockedGet.mockImplementation((parameter_name) => { diff --git a/lambdas/functions/webhook/src/webhook/index.ts b/lambdas/functions/webhook/src/webhook/index.ts index 6091f35ca9..f0f58b31aa 100644 --- a/lambdas/functions/webhook/src/webhook/index.ts +++ b/lambdas/functions/webhook/src/webhook/index.ts @@ -1,13 +1,12 @@ import { Webhooks } from '@octokit/webhooks'; -import { CheckRunEvent, WorkflowJobEvent } from '@octokit/webhooks-types'; +import { WorkflowJobEvent } from '@octokit/webhooks-types'; import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; import { IncomingHttpHeaders } from 'http'; import { Response } from '../lambda'; -import { RunnerMatcherConfig, sendActionRequest, sendWebhookEventToWorkflowJobQueue } from '../sqs'; -import ValidationError from '../ValidatonError'; +import ValidationError from '../ValidationError'; import { Config } from '../ConfigResolver'; - +import { dispatch } from '../runners/dispatch'; const supportedEvents = ['workflow_job']; const logger = createChildLogger('handler'); @@ -15,82 +14,16 @@ export async function handle(headers: IncomingHttpHeaders, body: string, config: init(headers); await verifySignature(headers, body); - const { event, eventType } = readEvent(headers, body); - logger.info(`Processing Github event ${event.action} for ${event.repository.full_name}`); - - validateRepoInAllowList(event, config); - - const response = await handleWorkflowJob(event, eventType, Config.matcherConfig!); - await sendWebhookEventToWorkflowJobQueue({ workflowJobEvent: event }, config); - return response; -} -function validateRepoInAllowList(event: WorkflowJobEvent, config: Config) { - if (config.repositoryAllowList.length > 0 && !config.repositoryAllowList.includes(event.repository.full_name)) { - logger.info(`Received event from unauthorized repository ${event.repository.full_name}`); - throw new ValidationError(403, `Received event from unauthorized repository ${event.repository.full_name}`); - } -} + const checkBodySizeResult = checkBodySize(body, headers); -async function handleWorkflowJob( - body: WorkflowJobEvent, - githubEvent: string, - matcherConfig: Array, -): Promise { - const installationId = getInstallationId(body); - if (body.action === 'queued') { - // sort the queuesConfig by order of matcher config exact match, with all true matches lined up ahead. - 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)) { - await sendActionRequest({ - id: body.workflow_job.id, - repositoryName: body.repository.name, - repositoryOwner: body.repository.owner.login, - eventType: githubEvent, - installationId: installationId, - queueId: queue.id, - queueFifo: queue.fifo, - repoOwnerType: body.repository.owner.type, - }); - logger.info(`Successfully queued job for ${body.repository.full_name} to the queue ${queue.id}`); - return { statusCode: 201 }; - } - } - logger.warn(`Received event contains runner labels '${body.workflow_job.labels}' that are not accepted.`); - return { - statusCode: 202, - body: `Received event contains runner labels '${body.workflow_job.labels}' that are not accepted.`, - }; + const { event, eventType } = readEvent(headers, body); + logger.info(`Github event ${event.action} accepted for ${event.repository.full_name}`); + if (checkBodySizeResult.sizeExceeded) { + // We only warn for large event, when moving the event bridge we can only can accept events up to 256KB + logger.warn('Body size exceeded 256KB', { size: checkBodySizeResult.message.size }); } - return { statusCode: 201 }; -} - -function getInstallationId(body: WorkflowJobEvent | CheckRunEvent) { - return body.installation?.id ?? 0; -} - -export function canRunJob( - workflowJobLabels: string[], - runnerLabelsMatchers: string[][], - workflowLabelCheckAll: boolean, -): boolean { - runnerLabelsMatchers = runnerLabelsMatchers.map((runnerLabel) => { - return runnerLabel.map((label) => label.toLowerCase()); - }); - const matchLabels = workflowLabelCheckAll - ? runnerLabelsMatchers.some((rl) => workflowJobLabels.every((wl) => rl.includes(wl.toLowerCase()))) - : runnerLabelsMatchers.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(',')}'`, - ); - return match; + return await dispatch(event, eventType, config); } async function verifySignature(headers: IncomingHttpHeaders, body: string): Promise { @@ -134,7 +67,7 @@ function readEvent(headers: IncomingHttpHeaders, body: string): { event: Workflo } const event = JSON.parse(body) as WorkflowJobEvent; - logger.addPersistentLogAttributes({ + logger.appendPersistentKeys({ github: { repository: event.repository.full_name, action: event.action, @@ -149,3 +82,23 @@ function readEvent(headers: IncomingHttpHeaders, body: string): { event: Workflo return { event, eventType }; } + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function checkBodySize(body: string, headers: IncomingHttpHeaders): { sizeExceeded: boolean; message: any } { + // GitHub does not specify if the content length is always present, fallback to the body size calculation. + const contentLength = Number(headers['content-length']) || Buffer.byteLength(body, 'utf8'); + const bodySizeInKiloBytes = contentLength / 1024; + + return bodySizeInKiloBytes > 256 + ? { + sizeExceeded: true, + message: { + error: 'Body size exceeded 256KB', + size: bodySizeInKiloBytes, + }, + } + : { + sizeExceeded: false, + message: undefined, + }; +} diff --git a/lambdas/functions/webhook/test/resources/multi_runner_configurations.json b/lambdas/functions/webhook/test/resources/multi_runner_configurations.json index a40eefd084..b90378fb16 100644 --- a/lambdas/functions/webhook/test/resources/multi_runner_configurations.json +++ b/lambdas/functions/webhook/test/resources/multi_runner_configurations.json @@ -1,7 +1,7 @@ [ { "id": "ubuntu-queue-id", - "arn": "queueARN", + "arn": "queueARN-ubuntu", "fifo": false, "matcherConfig": { "labelMatchers": [[ @@ -15,7 +15,7 @@ }, { "id": "latest-queue-id", - "arn": "queueARN", + "arn": "queueARN-latest", "fifo": false, "matcherConfig": { "labelMatchers": [[