Skip to content

Commit

Permalink
Feature: add support for github workflow_job event
Browse files Browse the repository at this point in the history
  • Loading branch information
npalm committed Jul 27, 2021
1 parent 6278c17 commit da13081
Show file tree
Hide file tree
Showing 12 changed files with 599 additions and 156 deletions.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,11 @@ A logical question would be why not Kubernetes? In the current approach we stay

## Overview

The moment a GitHub action workflow requiring a `self-hosted` runner is triggered, GitHub will try to find a runner which can execute the workload. This module reacts to GitHub's [`check_run` event](https://docs.github.com/en/free-pro-team@latest/developers/webhooks-and-events/webhook-events-and-payloads#check_run) for the triggered workflow and creates a new runner if necessary.
The moment a GitHub action workflow requiring a `self-hosted` runner is triggered, GitHub will try to find a runner which can execute the workload. This module reacts to GitHub's [`workflow_job` event](https://docs.github.com/en/free-pro-team@latest/developers/webhooks-and-events/webhook-events-and-payloads#workflow_job) for the triggered workflow and creates a new runner if necessary.

For receiving the `check_run` event, a GitHub App needs to be created with a webhook to which the event will be published. Installing the GitHub App in a specific repository or all repositories ensures the `check_run` event will be sent to the webhook.
For receiving the `workflow_job` event, a Webhook needs to be created. The webhook hook can be defined on enterprise, org, repo, or app level. When using the GitHub app ensure the app is installed in the specific repository or all repositories.

In AWS a [API gateway](https://docs.aws.amazon.com/apigateway/index.html) endpoint is created that is able to receive the GitHub webhook events via HTTP post. The gateway triggers the webhook lambda which will verify the signature of the event. This check guarantees the event is sent by the GitHub App. The lambda only handles `check_run` events with status `created`. The accepted events are posted on a SQS queue. Messages on this queue will be delayed for a configurable amount of seconds (default 30 seconds) to give the available runners time to pick up this build.
In AWS a [API gateway](https://docs.aws.amazon.com/apigateway/index.html) endpoint is created that is able to receive the GitHub webhook events via HTTP post. The gateway triggers the webhook lambda which will verify the signature of the event. This check guarantees the event is sent by the GitHub App. The lambda only handles `workflow_job` events with status `queued` and matching the runner labels. The accepted events are posted on a SQS queue. Messages on this queue will be delayed for a configurable amount of seconds (default 30 seconds) to give the available runners time to pick up this build.

The "scale up runner" lambda is listening to the SQS queue and picks up events. The lambda runs various checks to decide whether a new EC2 spot instance needs to be created. For example, the instance is not created if the build is already started by an existing runner, or the maximum number of runners is reached.

Expand All @@ -56,7 +56,7 @@ Secrets and private keys which are passed to the lambdas as environment variable

Permission are managed on several places. Below the most important ones. For details check the Terraform sources.

- The GitHub App requires access to actions and publish `check_run` events to AWS.
- The GitHub App requires access to actions and publish `workflow_job` events to the AWS webhook (API gateway).
- The scale up lambda should have access to EC2 for creating and tagging instances.
- The scale down lambda should have access to EC2 to terminate instances.

Expand Down
25 changes: 15 additions & 10 deletions examples/default/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@ resource "random_password" "random" {
length = 28
}

module "runners" {
source = "../../"

aws_region = local.aws_region
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
################################################################################
### Hybrid acccount
################################################################################

module "runners" {
source = "../../"
create_service_linked_role_spot = true
aws_region = local.aws_region
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets

environment = local.environment
tags = {
Expand All @@ -27,11 +32,11 @@ module "runners" {
webhook_secret = random_password.random.result
}

webhook_lambda_zip = "lambdas-download/webhook.zip"
runner_binaries_syncer_lambda_zip = "lambdas-download/runner-binaries-syncer.zip"
runners_lambda_zip = "lambdas-download/runners.zip"
enable_organization_runners = false
runner_extra_labels = "default,example"
# webhook_lambda_zip = "lambdas-download/webhook.zip"
# runner_binaries_syncer_lambda_zip = "lambdas-download/runner-binaries-syncer.zip"
# runners_lambda_zip = "lambdas-download/runners.zip"
enable_organization_runners = true
runner_extra_labels = "default,example"

# enable access to the runners via SSM
enable_ssm_on_runners = true
Expand Down
1 change: 1 addition & 0 deletions main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ module "webhook" {
lambda_zip = var.webhook_lambda_zip
lambda_timeout = var.webhook_lambda_timeout
logging_retention_in_days = var.logging_retention_in_days
runner_extra_labels = vars.runner_extra_labels

role_path = var.role_path
role_permissions_boundary = var.role_permissions_boundary
Expand Down
83 changes: 50 additions & 33 deletions modules/runners/lambdas/runners/src/scale-runners/scale-up.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const mockOctokit = {
actions: {
createRegistrationTokenForOrg: jest.fn(),
createRegistrationTokenForRepo: jest.fn(),
getJobForWorkflowRun: jest.fn(),
},
apps: {
getOrgInstallation: jest.fn(),
Expand All @@ -26,15 +27,15 @@ jest.mock('./runners');

const TEST_DATA: scaleUpModule.ActionRequestMessage = {
id: 1,
eventType: 'check_run',
eventType: 'workflow_job',
repositoryName: 'hello-world',
repositoryOwner: 'Codertocat',
installationId: 2,
};

const TEST_DATA_WITHOUT_INSTALL_ID: scaleUpModule.ActionRequestMessage = {
id: 3,
eventType: 'check_run',
eventType: 'workflow_job',
repositoryName: 'hello-world',
repositoryOwner: 'Codertocat',
installationId: 0,
Expand All @@ -48,7 +49,7 @@ const EXPECTED_RUNNER_PARAMS: RunnerInputParameters = {
environment: 'unit-test-environment',
runnerServiceConfig: `--url https://github.enterprise.something/${TEST_DATA.repositoryOwner} --token 1234abcd `,
runnerType: 'Org',
runnerOwner: TEST_DATA.repositoryOwner
runnerOwner: TEST_DATA.repositoryOwner,
};
let expectedRunnerParams: RunnerInputParameters;

Expand All @@ -65,6 +66,12 @@ beforeEach(() => {
process.env.ENVIRONMENT = 'unit-test-environment';
process.env.LAUNCH_TEMPLATE_NAME = 'lt-1,lt-2';

mockOctokit.actions.getJobForWorkflowRun.mockImplementation(() => ({
data: {
status: 'queued',
},
}));

mockOctokit.checks.get.mockImplementation(() => ({
data: {
status: 'queued',
Expand Down Expand Up @@ -113,16 +120,16 @@ describe('scaleUp with GHES', () => {

it('checks queued workflows', async () => {
await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
expect(mockOctokit.checks.get).toBeCalledWith({
check_run_id: TEST_DATA.id,
expect(mockOctokit.actions.getJobForWorkflowRun).toBeCalledWith({
job_id: TEST_DATA.id,
owner: TEST_DATA.repositoryOwner,
repo: TEST_DATA.repositoryName,
});
});

it('does not list runners when no workflows are queued', async () => {
mockOctokit.checks.get.mockImplementation(() => ({
data: { total_count: 0, runners: [] },
mockOctokit.actions.getJobForWorkflowRun.mockImplementation(() => ({
data: { total_count: 0 },
}));
await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
expect(listRunners).not.toBeCalled();
Expand All @@ -139,7 +146,7 @@ describe('scaleUp with GHES', () => {
expect(listRunners).toBeCalledWith({
environment: 'unit-test-environment',
runnerType: 'Org',
runnerOwner: TEST_DATA.repositoryOwner
runnerOwner: TEST_DATA.repositoryOwner,
});
});

Expand All @@ -165,20 +172,20 @@ describe('scaleUp with GHES', () => {
expect(spy).toBeCalledWith(
TEST_DATA.installationId,
'installation',
"https://github.enterprise.something/api/v3"
'https://github.enterprise.something/api/v3',
);
});

it('retrieves installation id if not set', async () => {
const spy = jest.spyOn(ghAuth, 'createGithubAuth');
await scaleUpModule.scaleUp('aws:sqs', TEST_DATA_WITHOUT_INSTALL_ID);
expect(mockOctokit.apps.getRepoInstallation).not.toBeCalled();
expect(spy).toHaveBeenNthCalledWith(1, undefined, 'app', "https://github.enterprise.something/api/v3");
expect(spy).toHaveBeenNthCalledWith(1, undefined, 'app', 'https://github.enterprise.something/api/v3');
expect(spy).toHaveBeenNthCalledWith(
2,
TEST_DATA.installationId,
'installation',
"https://github.enterprise.something/api/v3"
'https://github.enterprise.something/api/v3',
);
});

Expand All @@ -187,12 +194,17 @@ describe('scaleUp with GHES', () => {
expect(createRunner).toBeCalledWith(expectedRunnerParams, 'lt-1');
});

it('creates a runner with legacy event check_run', async () => {
await scaleUpModule.scaleUp('aws:sqs', { ...TEST_DATA, eventType: 'check_run' });
expect(createRunner).toBeCalledWith(expectedRunnerParams, 'lt-1');
});

it('creates a runner with labels in a specific group', async () => {
process.env.RUNNER_EXTRA_LABELS = 'label1,label2';
process.env.RUNNER_GROUP_NAME = 'TEST_GROUP';
await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
expectedRunnerParams.runnerServiceConfig = expectedRunnerParams.runnerServiceConfig +
`--labels label1,label2 --runnergroup TEST_GROUP`;
expectedRunnerParams.runnerServiceConfig =
expectedRunnerParams.runnerServiceConfig + `--labels label1,label2 --runnergroup TEST_GROUP`;
expect(createRunner).toBeCalledWith(expectedRunnerParams, 'lt-1');
});

Expand Down Expand Up @@ -223,7 +235,8 @@ describe('scaleUp with GHES', () => {
expectedRunnerParams = { ...EXPECTED_RUNNER_PARAMS };
expectedRunnerParams.runnerType = 'Repo';
expectedRunnerParams.runnerOwner = `${TEST_DATA.repositoryOwner}/${TEST_DATA.repositoryName}`;
expectedRunnerParams.runnerServiceConfig = `--url ` +
expectedRunnerParams.runnerServiceConfig =
`--url ` +
`https://github.enterprise.something/${TEST_DATA.repositoryOwner}/${TEST_DATA.repositoryName} ` +
`--token 1234abcd `;
});
Expand Down Expand Up @@ -259,20 +272,20 @@ describe('scaleUp with GHES', () => {
expect(spy).toBeCalledWith(
TEST_DATA.installationId,
'installation',
"https://github.enterprise.something/api/v3"
'https://github.enterprise.something/api/v3',
);
});

it('retrieves installation id if not set', async () => {
const spy = jest.spyOn(ghAuth, 'createGithubAuth');
await scaleUpModule.scaleUp('aws:sqs', TEST_DATA_WITHOUT_INSTALL_ID);
expect(mockOctokit.apps.getOrgInstallation).not.toBeCalled();
expect(spy).toHaveBeenNthCalledWith(1, undefined, 'app', "https://github.enterprise.something/api/v3");
expect(spy).toHaveBeenNthCalledWith(1, undefined, 'app', 'https://github.enterprise.something/api/v3');
expect(spy).toHaveBeenNthCalledWith(
2,
TEST_DATA.installationId,
'installation',
"https://github.enterprise.something/api/v3"
'https://github.enterprise.something/api/v3',
);
});

Expand All @@ -289,7 +302,8 @@ describe('scaleUp with GHES', () => {
process.env.RUNNER_EXTRA_LABELS = 'label1,label2';
process.env.RUNNER_GROUP_NAME = 'TEST_GROUP_IGNORED';
await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
expectedRunnerParams.runnerServiceConfig = `--url ` +
expectedRunnerParams.runnerServiceConfig =
`--url ` +
`https://github.enterprise.something/${TEST_DATA.repositoryOwner}/${TEST_DATA.repositoryName} ` +
`--token 1234abcd --labels label1,label2`;
expectedRunnerParams.runnerOwner = `${TEST_DATA.repositoryOwner}/${TEST_DATA.repositoryName}`;
Expand All @@ -315,8 +329,8 @@ describe('scaleUp with public GH', () => {

it('checks queued workflows', async () => {
await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
expect(mockOctokit.checks.get).toBeCalledWith({
check_run_id: TEST_DATA.id,
expect(mockOctokit.actions.getJobForWorkflowRun).toBeCalledWith({
job_id: TEST_DATA.id,
owner: TEST_DATA.repositoryOwner,
repo: TEST_DATA.repositoryName,
});
Expand All @@ -327,19 +341,19 @@ describe('scaleUp with public GH', () => {
await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
expect(mockOctokit.apps.getOrgInstallation).not.toBeCalled();
expect(mockOctokit.apps.getRepoInstallation).not.toBeCalled();
expect(spy).toBeCalledWith(TEST_DATA.installationId, 'installation', "");
expect(spy).toBeCalledWith(TEST_DATA.installationId, 'installation', '');
});

it('retrieves installation id if not set', async () => {
const spy = jest.spyOn(ghAuth, 'createGithubAuth');
await scaleUpModule.scaleUp('aws:sqs', TEST_DATA_WITHOUT_INSTALL_ID);
expect(mockOctokit.apps.getRepoInstallation).not.toBeCalled();
expect(spy).toHaveBeenNthCalledWith(1, undefined, 'app', "");
expect(spy).toHaveBeenNthCalledWith(2, TEST_DATA.installationId, 'installation', "");
expect(spy).toHaveBeenNthCalledWith(1, undefined, 'app', '');
expect(spy).toHaveBeenNthCalledWith(2, TEST_DATA.installationId, 'installation', '');
});

it('does not list runners when no workflows are queued', async () => {
mockOctokit.checks.get.mockImplementation(() => ({
mockOctokit.actions.getJobForWorkflowRun.mockImplementation(() => ({
data: { status: 'completed' },
}));
await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
Expand All @@ -350,16 +364,15 @@ describe('scaleUp with public GH', () => {
beforeEach(() => {
process.env.ENABLE_ORGANIZATION_RUNNERS = 'true';
expectedRunnerParams = { ...EXPECTED_RUNNER_PARAMS };
expectedRunnerParams.runnerServiceConfig =
`--url https://github.com/${TEST_DATA.repositoryOwner} --token 1234abcd `;
expectedRunnerParams.runnerServiceConfig = `--url https://github.com/${TEST_DATA.repositoryOwner} --token 1234abcd `;
});

it('gets the current org level runners', async () => {
await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
expect(listRunners).toBeCalledWith({
environment: 'unit-test-environment',
runnerType: 'Org',
runnerOwner: TEST_DATA.repositoryOwner
runnerOwner: TEST_DATA.repositoryOwner,
});
});

Expand All @@ -382,6 +395,11 @@ describe('scaleUp with public GH', () => {
expect(createRunner).toBeCalledWith(expectedRunnerParams, LAUNCH_TEMPLATE);
});

it('creates a runner with legacy event check_run', async () => {
await scaleUpModule.scaleUp('aws:sqs', { ...TEST_DATA, eventType: 'check_run' });
expect(createRunner).toBeCalledWith(expectedRunnerParams, LAUNCH_TEMPLATE);
});

it('creates a runner with labels in s specific group', async () => {
process.env.RUNNER_EXTRA_LABELS = 'label1,label2';
process.env.RUNNER_GROUP_NAME = 'TEST_GROUP';
Expand All @@ -407,9 +425,8 @@ describe('scaleUp with public GH', () => {
expectedRunnerParams = { ...EXPECTED_RUNNER_PARAMS };
expectedRunnerParams.runnerType = 'Repo';
expectedRunnerParams.runnerOwner = `${TEST_DATA.repositoryOwner}/${TEST_DATA.repositoryName}`;
expectedRunnerParams.runnerServiceConfig = `--url ` +
`https://github.com/${TEST_DATA.repositoryOwner}/${TEST_DATA.repositoryName} ` +
`--token 1234abcd `;
expectedRunnerParams.runnerServiceConfig =
`--url ` + `https://github.com/${TEST_DATA.repositoryOwner}/${TEST_DATA.repositoryName} ` + `--token 1234abcd `;
});

it('gets the current repo level runners', async () => {
Expand Down Expand Up @@ -440,15 +457,15 @@ describe('scaleUp with public GH', () => {
await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
expect(mockOctokit.apps.getOrgInstallation).not.toBeCalled();
expect(mockOctokit.apps.getRepoInstallation).not.toBeCalled();
expect(spy).toBeCalledWith(TEST_DATA.installationId, 'installation', "");
expect(spy).toBeCalledWith(TEST_DATA.installationId, 'installation', '');
});

it('retrieves installation id if not set', async () => {
const spy = jest.spyOn(ghAuth, 'createGithubAuth');
await scaleUpModule.scaleUp('aws:sqs', TEST_DATA_WITHOUT_INSTALL_ID);
expect(mockOctokit.apps.getOrgInstallation).not.toBeCalled();
expect(spy).toHaveBeenNthCalledWith(1, undefined, 'app', "");
expect(spy).toHaveBeenNthCalledWith(2, TEST_DATA.installationId, 'installation', "");
expect(spy).toHaveBeenNthCalledWith(1, undefined, 'app', '');
expect(spy).toHaveBeenNthCalledWith(2, TEST_DATA.installationId, 'installation', '');
});

it('creates a runner with correct config and labels', async () => {
Expand Down
Loading

0 comments on commit da13081

Please sign in to comment.