Skip to content

feat(ghes): Support for GitHub Enterprise Server #412

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jan 14, 2021
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .ci/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules
1 change: 1 addition & 0 deletions .ci/build.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env bash
set -e

lambdaSrcDirs=("modules/runner-binaries-syncer/lambdas/runner-binaries-syncer" "modules/runners/lambdas/runners" "modules/webhook/lambdas/webhook")
repoRoot=$(dirname $(dirname $(realpath ${BASH_SOURCE[0]})))
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ example/*.secrets*.tfvars
*.gz
*.tgz
*.env
.vscode

**/coverage/*
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,18 @@ Go to GitHub and create a new app. Beware you can create apps your organization
2. Choose a name
3. Choose a website (mandatory, not required for the module).
4. Disable the webhook for now (we will configure this later).
5. Repository permissions, enable `Checks` to receive events for new builds.
6. _Only for repo level runners!_ - Repository permissions, `Administration` - Read and Write (to register runner)
7. _Only for organization level runners!_ - Organization permissions, `Administration` - Read and Write (to register runner)
5. Permissions for all runners:
- Repository:
- `Actions`: Read-only (check for queued jobs)
- `Checks`: Read-only (receive events for new builds)
- `Metadata`: Read-only (default/required)
6. _Permissions for repo level runners only_:
- Repository:
- `Administration`: Read & write (to register runner)
7. _Permissions for organization level runners only_:
- Organization
- `Administration`: Read & write (to register runner)
- `Self-hosted runners`: Read & write (to register runner)
8. Save the new app.
9. On the General page, make a note of the "App ID" and "Client ID" parameters.
10. Create a new client secret and also write it down.
Expand Down Expand Up @@ -306,12 +315,16 @@ No requirements.
| enable\_ssm\_on\_runners | Enable to allow access the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances. | `bool` | `false` | no |
| encrypt\_secrets | Encrypt secret variables for lambda's such as secrets and private keys. | `bool` | `true` | no |
| environment | A name that identifies the environment, used as prefix and for tagging. | `string` | n/a | yes |
| ghes\_url | GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB | `string` | `null` | no |
| github\_app | GitHub app parameters, see your github app. Ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`). | <pre>object({<br> key_base64 = string<br> id = string<br> client_id = string<br> client_secret = string<br> webhook_secret = string<br> })</pre> | n/a | yes |
| idle\_config | List of time period that can be defined as cron expression to keep a minimum amount of runners active instead of scaling down to 0. By defining this list you can ensure that in time periods that match the cron expression within 5 seconds a runner is kept idle. | <pre>list(object({<br> cron = string<br> timeZone = string<br> idleCount = number<br> }))</pre> | `[]` | no |
| instance\_profile\_path | The path that will be added to the instance\_profile, if not set the environment name will be used. | `string` | `null` | no |
| instance\_type | Instance type for the action runner. | `string` | `"m5.large"` | no |
| key\_name | Key pair name | `string` | `null` | no |
| kms\_key\_id | Custom KMS key to encrypted lambda secrets, if not provided and `encrypt_secrets` = `true` a KMS key will be created by the module. Secrets will be encrypted with a context `Environment = var.environment`. | `string` | `null` | no |
| lambda\_s3\_bucket | S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly. | `any` | `null` | no |
| lambda\_security\_group\_ids | List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. | `list(string)` | `[]` | no |
| lambda\_subnet\_ids | List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. | `list(string)` | `[]` | no |
| logging\_retention\_in\_days | Specifies the number of days you want to retain log events for the lambda log group. Possible values are: 0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, and 3653. | `number` | `7` | no |
| manage\_kms\_key | Let the module manage the KMS key. | `bool` | `true` | no |
| minimum\_running\_time\_in\_minutes | The time an ec2 action runner should be running at minimum before terminated if non busy. | `number` | `5` | no |
Expand All @@ -330,7 +343,7 @@ No requirements.
| runners\_lambda\_zip | File location of the lambda zip file for scaling runners. | `string` | `null` | no |
| runners\_maximum\_count | The maximum number of runners that will be created. | `number` | `3` | no |
| runners\_scale\_down\_lambda\_timeout | Time out for the scale up lambda in seconds. | `number` | `60` | no |
| runners\_scale\_up\_lambda\_timeout | Time out for the scale down lambda in seconds. | `number` | `60` | no |
| runners\_scale\_up\_lambda\_timeout | Time out for the scale down lambda in seconds. | `number` | `180` | no |
| scale\_down\_schedule\_expression | Scheduler expression to check every x for scale down. | `string` | `"cron(*/5 * * * ? *)"` | no |
| subnet\_ids | List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. | `list(string)` | n/a | yes |
| syncer\_lambda\_s3\_key | S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas. | `any` | `null` | no |
Expand Down
5 changes: 5 additions & 0 deletions main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ module "runners" {
lambda_zip = var.runners_lambda_zip
lambda_timeout_scale_up = var.runners_scale_up_lambda_timeout
lambda_timeout_scale_down = var.runners_scale_down_lambda_timeout
lambda_subnet_ids = var.lambda_subnet_ids
lambda_security_group_ids = var.lambda_security_group_ids
logging_retention_in_days = var.logging_retention_in_days
enable_cloudwatch_agent = var.enable_cloudwatch_agent
cloudwatch_config = var.cloudwatch_config
Expand All @@ -104,10 +106,13 @@ module "runners" {
userdata_template = var.userdata_template
userdata_pre_install = var.userdata_pre_install
userdata_post_install = var.userdata_post_install
key_name = var.key_name

create_service_linked_role_spot = var.create_service_linked_role_spot

runner_iam_role_managed_policy_arns = var.runner_iam_role_managed_policy_arns

ghes_url = var.ghes_url
}

module "runner_binaries" {
Expand Down
19 changes: 19 additions & 0 deletions modules/download-lambda/.terraform.lock.hcl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions modules/runner-binaries-syncer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ No requirements.
| environment | A name that identifies the environment, used as prefix and for tagging. | `string` | n/a | yes |
| lambda\_s3\_bucket | S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly. | `any` | `null` | no |
| lambda\_schedule\_expression | Scheduler expression for action runner binary syncer. | `string` | `"cron(27 * * * ? *)"` | no |
| lambda\_security\_group\_ids | List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. | `list(string)` | `[]` | no |
| lambda\_subnet\_ids | List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. | `list(string)` | `[]` | no |
| lambda\_timeout | Time out of the lambda in seconds. | `number` | `300` | no |
| lambda\_zip | File location of the lambda zip file. | `string` | `null` | no |
| logging\_retention\_in\_days | Specifies the number of days you want to retain log events for the lambda log group. Possible values are: 0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, and 3653. | `number` | `7` | no |
Expand Down
7 changes: 7 additions & 0 deletions modules/runner-binaries-syncer/runner-binaries-syncer.tf
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ resource "aws_lambda_function" "syncer" {
GITHUB_RUNNER_ALLOW_PRERELEASE_BINARIES = var.runner_allow_prerelease_binaries
}
}
dynamic "vpc_config" {
for_each = var.lambda_subnet_ids != null && var.lambda_security_group_ids != null ? [true] : []
content {
security_group_ids = var.lambda_security_group_ids
subnet_ids = var.lambda_subnet_ids
}
}

tags = var.tags
}
Expand Down
11 changes: 11 additions & 0 deletions modules/runner-binaries-syncer/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,14 @@ variable "syncer_lambda_s3_object_version" {
default = null
}

variable "lambda_subnet_ids" {
description = "List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`."
type = list(string)
default = []
}

variable "lambda_security_group_ids" {
description = "List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`."
type = list(string)
default = []
}
4 changes: 4 additions & 0 deletions modules/runners/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,15 @@ No requirements.
| enable\_ssm\_on\_runners | Enable to allow access the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances. | `bool` | n/a | yes |
| encryption | KMS key to encrypted lambda environment secrets. Either provide a key and `encrypt` set to `true`. Or set the key to `null` and encrypt to `false`. | <pre>object({<br> kms_key_id = string<br> encrypt = bool<br> })</pre> | n/a | yes |
| environment | A name that identifies the environment, used as prefix and for tagging. | `string` | n/a | yes |
| ghes\_url | GitHub Enterprise Server URL. DO NOT SET IF USING PUBLIC GITHUB | `string` | `""` | no |
| github\_app | GitHub app parameters, see your github app. Ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`). | <pre>object({<br> key_base64 = string<br> id = string<br> client_id = string<br> client_secret = string<br> })</pre> | n/a | yes |
| idle\_config | List of time period that can be defined as cron expression to keep a minimum amount of runners active instead of scaling down to 0. By defining this list you can ensure that in time periods that match the cron expression within 5 seconds a runner is kept idle. | <pre>list(object({<br> cron = string<br> timeZone = string<br> idleCount = number<br> }))</pre> | `[]` | no |
| instance\_profile\_path | The path that will be added to the instance\_profile, if not set the environment name will be used. | `string` | `null` | no |
| instance\_type | Default instance type for the action runner. | `string` | `"m5.large"` | no |
| key\_name | Key pair name | `string` | `null` | no |
| lambda\_s3\_bucket | S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly. | `any` | `null` | no |
| lambda\_security\_group\_ids | List of subnets in which the lambda will be launched, the subnets needs to be subnets in the `vpc_id`. | `list(string)` | `[]` | no |
| lambda\_subnet\_ids | List of subnets in which the lambda will be launched, the subnets needs to be subnets in the `vpc_id`. | `list(string)` | `[]` | no |
| lambda\_timeout\_scale\_down | Time out for the scale down lambda in seconds. | `number` | `60` | no |
| lambda\_timeout\_scale\_up | Time out for the scale up lambda in seconds. | `number` | `60` | no |
| lambda\_zip | File location of the lambda zip file. | `string` | `null` | no |
Expand Down
3 changes: 2 additions & 1 deletion modules/runners/lambdas/runners/.prettierrc
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"printWidth": 120,
"singleQuote": true,
"trailingComma": "all"
"trailingComma": "all",
"semi": false,
}
10 changes: 10 additions & 0 deletions modules/runners/lambdas/runners/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
collectCoverage: true,
collectCoverageFrom: ['src/**/*.{ts,js,jsx}'],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
17 changes: 12 additions & 5 deletions modules/runners/lambdas/runners/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,32 @@
"test": "NODE_ENV=test jest",
"watch": "ts-node-dev --respawn --exit-child src/local.ts",
"build": "ncc build src/lambda.ts -o dist",
"dist": "yarn build && cd dist && zip ../runners.zip index.js"
"dist": "yarn build && cd dist && zip ../runners.zip index.js",
"format": "prettier --write \"**/*.ts\"",
"format-check": "prettier --check \"**/*.ts\""
},
"devDependencies": {
"@types/aws-lambda": "^8.10.68",
"@types/express": "^4.17.9",
"@types/jest": "^26.0.19",
"@types/node": "^14.14.16",
"@vercel/ncc": "^0.26.1",
"aws-sdk": "^2.817.0",
"jest": "^26.6.3",
"jest-mock-extended": "^1.0.10",
"nock": "^13.0.5",
"ts-jest": "^26.4.4",
"ts-node-dev": "^1.1.1",
"typescript": "^4.1.3"
"ts-node-dev": "^1.1.1"
},
"dependencies": {
"@octokit/auth-app": "^2.10.5",
"@octokit/rest": "^18.0.12",
"@octokit/types": "^6.1.1",
"@types/aws-lambda": "^8.10.68",
"@types/express": "^4.17.9",
"@types/node": "^14.14.16",
"aws-sdk": "^2.817.0",
"cron-parser": "^2.18.0",
"moment": "^2.29.1",
"typescript": "^4.1.3",
"yn": "^4.0.0"
}
}
149 changes: 149 additions & 0 deletions modules/runners/lambdas/runners/src/scale-runners/gh-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { createOctoClient, createGithubAuth } from './gh-auth'
import nock from 'nock'
import { createAppAuth } from '@octokit/auth-app'
import { StrategyOptions } from '@octokit/auth-app/dist-types/types'
import { decrypt } from './kms'
import { RequestInterface } from '@octokit/types'
import { mock, MockProxy } from 'jest-mock-extended'
import { request } from '@octokit/request'

jest.mock('./kms')
jest.mock('@octokit/auth-app')

const cleanEnv = process.env

beforeEach(() => {
jest.resetModules()
jest.clearAllMocks()
process.env = { ...cleanEnv }
nock.disableNetConnect()
})

describe('Test createGithubAuth', () => {
test('Creates app client to GitHub public', async () => {
// Arrange
const token = '123456'

// Act
const result = await createOctoClient(token)

// Assert
expect(result.request.endpoint.DEFAULTS.baseUrl).toBe('https://api.github.com')
})

test('Creates app client to GitHub ES', async () => {
// Arrange
const enterpriseServer = 'https://github.enterprise.notgoingtowork'
const token = '123456'

// Act
const result = await createOctoClient(token, enterpriseServer)

// Assert
expect(result.request.endpoint.DEFAULTS.baseUrl).toBe(enterpriseServer)
})
})

describe('Test createGithubAuth', () => {
const mockedDecrypt = (decrypt as unknown) as jest.Mock
const mockedCreatAppAuth = (createAppAuth as unknown) as jest.Mock
const mockedDefaults = jest.spyOn(request, 'defaults')
let mockedRequestInterface: MockProxy<RequestInterface>

const installationId = 1
const authType = 'app'
const token = '123456'
const decryptedValue = 'decryptedValue'
const b64 = Buffer.from(decryptedValue, 'binary').toString('base64')

beforeEach(() => {
process.env.GITHUB_APP_ID = '1'
process.env.GITHUB_APP_CLIENT_SECRET = 'client_secret'
process.env.GITHUB_APP_KEY_BASE64 = 'base64'
process.env.KMS_KEY_ID = 'key_id'
process.env.ENVIRONMENT = 'dev'
process.env.GITHUB_APP_CLIENT_ID = '1'
})

test('Creates auth object for public GitHub', async () => {
// Arrange
const authOptions = {
appId: parseInt(process.env.GITHUB_APP_ID as string),
privateKey: 'decryptedValue',
installationId,
clientId: process.env.GITHUB_APP_CLIENT_ID,
clientSecret: 'decryptedValue',
}

mockedDecrypt.mockResolvedValueOnce(decryptedValue).mockResolvedValueOnce(b64)
const mockedAuth = jest.fn()
mockedAuth.mockResolvedValue({ token })
mockedCreatAppAuth.mockImplementation((authOptions: StrategyOptions) => {
return mockedAuth
})

// Act
const result = await createGithubAuth(installationId, authType)

// Assert
expect(mockedDecrypt).toBeCalledWith(
process.env.GITHUB_APP_CLIENT_SECRET,
process.env.KMS_KEY_ID,
process.env.ENVIRONMENT,
)
expect(mockedDecrypt).toBeCalledWith(
process.env.GITHUB_APP_KEY_BASE64,
process.env.KMS_KEY_ID,
process.env.ENVIRONMENT,
)
expect(mockedCreatAppAuth).toBeCalledTimes(1)
expect(mockedCreatAppAuth).toBeCalledWith(authOptions)
expect(mockedAuth).toBeCalledWith({ type: authType })
expect(result.token).toBe(token)
})

test('Creates auth object for Enterprise Server', async () => {
// Arrange
const githubServerUrl = 'https://github.enterprise.notgoingtowork'

mockedRequestInterface = mock<RequestInterface>()
mockedDefaults.mockImplementation(() => {
return mockedRequestInterface.defaults({ baseUrl: githubServerUrl })
})

const authOptions = {
appId: parseInt(process.env.GITHUB_APP_ID as string),
privateKey: 'decryptedValue',
installationId,
clientId: process.env.GITHUB_APP_CLIENT_ID,
clientSecret: 'decryptedValue',
request: mockedRequestInterface.defaults({ baseUrl: githubServerUrl }),
}

mockedDecrypt.mockResolvedValueOnce(decryptedValue).mockResolvedValueOnce(b64)
const mockedAuth = jest.fn()
mockedAuth.mockResolvedValue({ token })
mockedCreatAppAuth.mockImplementation((authOptions: StrategyOptions) => {
return mockedAuth
})

// Act
const result = await createGithubAuth(installationId, authType, githubServerUrl)

// Assert
expect(mockedDecrypt).toBeCalledWith(
process.env.GITHUB_APP_CLIENT_SECRET,
process.env.KMS_KEY_ID,
process.env.ENVIRONMENT,
)
expect(mockedDecrypt).toBeCalledWith(
process.env.GITHUB_APP_KEY_BASE64,
process.env.KMS_KEY_ID,
process.env.ENVIRONMENT,
)
expect(mockedCreatAppAuth).toBeCalledTimes(1)
expect(mockedCreatAppAuth).toBeCalledWith(authOptions)
expect(mockedAuth).toBeCalledWith({ type: authType })
expect(result.token).toBe(token)
})
})
Loading