diff --git a/.gitignore b/.gitignore index 9de7a7047d..73cc8a1bc8 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ secrets.auto.tfvars *.tgz *.env* .vscode +.project +.settings/ **/coverage/* diff --git a/README.md b/README.md index 19cf7fa98e..15e72658c4 100644 --- a/README.md +++ b/README.md @@ -415,6 +415,7 @@ In case the setup does not work as intended follow the trace of events: | [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | | [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB | `string` | `null` | no | | [github\_app](#input\_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`). |
object({
key_base64 = string
id = string
webhook_secret = string
})
| n/a | yes | +| [http\_proxy](#input\http\_proxy) | Http(s) proxy used by scale up/down runners Lambda to interact with AWS APIs (SSM, EC2), and usable in user data template. To use when `vpc_id` has no direct internet connection. | `string` | `null` | no | | [idle\_config](#input\_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. |
list(object({
cron = string
timeZone = string
idleCount = number
}))
| `[]` | no | | [instance\_allocation\_strategy](#input\_instance\_allocation\_strategy) | The allocation strategy for spot instances. AWS recommends to use `capacity-optimized` however the AWS default is `lowest-price`. | `string` | `"lowest-price"` | no | | [instance\_max\_spot\_price](#input\_instance\_max\_spot\_price) | Max price price for spot intances per hour. This variable will be passed to the create fleet as max spot price for the fleet. | `string` | `null` | no | diff --git a/main.tf b/main.tf index 3a3525bd3b..9b2ae8b306 100644 --- a/main.tf +++ b/main.tf @@ -182,6 +182,7 @@ module "runners" { 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 + http_proxy = var.http_proxy logging_retention_in_days = var.logging_retention_in_days logging_kms_key_id = var.logging_kms_key_id enable_cloudwatch_agent = var.enable_cloudwatch_agent diff --git a/modules/runners/README.md b/modules/runners/README.md index 6fb103555a..739ba1ca84 100644 --- a/modules/runners/README.md +++ b/modules/runners/README.md @@ -135,6 +135,7 @@ yarn run dist | [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | | [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. DO NOT SET IF USING PUBLIC GITHUB | `string` | `null` | no | | [github\_app\_parameters](#input\_github\_app\_parameters) | Parameter Store for GitHub App Parameters. |
object({
key_base64 = map(string)
id = map(string)
})
| n/a | yes | +| [http\_proxy](#input\http\_proxy) | Http(s) proxy used by scale up/down runners Lambda to interact with AWS APIs (SSM, EC2), and usable in user data template. To use when `vpc_id` has no direct internet connection. | `string` | `null` | no | | [idle\_config](#input\_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. |
list(object({
cron = string
timeZone = string
idleCount = number
}))
| `[]` | no | | [instance\_allocation\_strategy](#input\_instance\_allocation\_strategy) | The allocation strategy for spot instances. AWS recommends to use `capacity-optimized` however the AWS default is `lowest-price`. | `string` | `"lowest-price"` | no | | [instance\_max\_spot\_price](#input\_instance\_max\_spot\_price) | Max price price for spot intances per hour. This variable will be passed to the create fleet as max spot price for the fleet. | `string` | `null` | no | diff --git a/modules/runners/lambdas/runners/package.json b/modules/runners/lambdas/runners/package.json index c0616275b6..5cfa28a78f 100644 --- a/modules/runners/lambdas/runners/package.json +++ b/modules/runners/lambdas/runners/package.json @@ -10,7 +10,7 @@ "lint": "yarn eslint src", "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 *.js", "format": "prettier --write \"**/*.ts\"", "format-check": "prettier --check \"**/*.ts\"", "all": "yarn build && yarn format && yarn lint && yarn test" @@ -44,6 +44,7 @@ "@types/express": "^4.17.11", "@types/node": "^18.7.6", "aws-sdk": "^2.1196.0", + "proxy-agent": "^5.0.0", "cron-parser": "^4.6.0", "tslog": "^3.3.3", "typescript": "^4.7.4" diff --git a/modules/runners/lambdas/runners/src/aws/ssm.test.ts b/modules/runners/lambdas/runners/src/aws/ssm.test.ts index 8cd82b0d46..c72ff5bfbf 100644 --- a/modules/runners/lambdas/runners/src/aws/ssm.test.ts +++ b/modules/runners/lambdas/runners/src/aws/ssm.test.ts @@ -1,9 +1,11 @@ import { GetParameterCommandOutput, SSM } from '@aws-sdk/client-ssm'; import nock from 'nock'; +import proxy from 'proxy-agent'; import { getParameterValue } from './ssm'; jest.mock('@aws-sdk/client-ssm'); +jest.mock('proxy-agent'); const cleanEnv = process.env; @@ -12,6 +14,8 @@ beforeEach(() => { jest.clearAllMocks(); process.env = { ...cleanEnv }; nock.disableNetConnect(); + // Remove any proxy if existing (from user/execution 'clean' env or from previous test) + process.env.HTTPS_PROXY = undefined; }); describe('Test getParameterValue', () => { @@ -56,4 +60,57 @@ describe('Test getParameterValue', () => { // Assert expect(result).toBe(undefined); }); + + test('Check that proxy is not used if not defined', async () => { + // Mock it + const mockedProxy = proxy as unknown as jest.Mock; + + // Act + await getParameterValue('testParam'); + + // Assert not called + expect(mockedProxy).not.toHaveBeenCalled(); + }); + + test('Check that proxy is used', async () => { + // Define fake proxy + process.env.HTTPS_PROXY = 'http://proxy.company.com'; + + // Mock it + const mockedProxy = proxy as unknown as jest.Mock; + + // Act + await getParameterValue('testParam'); + + // Assert correctly called + expect(mockedProxy).toBeCalledWith(process.env.HTTPS_PROXY); + }); + + test('Check that invalid proxy is not used', async () => { + // Define fake invalid proxy + process.env.HTTPS_PROXY = 'invalidPrefix://proxy.company.com'; + + // Mock it + const mockedProxy = proxy as unknown as jest.Mock; + + // Act + await getParameterValue('testParam'); + + // Assert not called + expect(mockedProxy).not.toHaveBeenCalled(); + }); + + test('Check proxy unknown host', async () => { + // Define proxy which is unknown + process.env.HTTPS_PROXY = 'http://unknown.company.com'; + + // Mock it + const mockedProxy = proxy as unknown as jest.Mock; + mockedProxy.mockImplementation(() => { + throw new Error('Unknown host'); + }); + + // Assert exception + await expect(getParameterValue('testParam')).rejects.toHaveProperty('message', 'Unknown host'); + }); }); diff --git a/modules/runners/lambdas/runners/src/aws/ssm.ts b/modules/runners/lambdas/runners/src/aws/ssm.ts index 5212914321..19c321a011 100644 --- a/modules/runners/lambdas/runners/src/aws/ssm.ts +++ b/modules/runners/lambdas/runners/src/aws/ssm.ts @@ -1,6 +1,22 @@ import { SSM } from '@aws-sdk/client-ssm'; +import { NodeHttpHandler } from '@aws-sdk/node-http-handler'; +import proxy from 'proxy-agent'; + +import { logger } from '../logger'; +import { hideUrlPassword } from '../utils/url'; export async function getParameterValue(parameter_name: string): Promise { - const client = new SSM({ region: process.env.AWS_REGION }); + // Proxy with aws-sdk v3 + // Configured by client (global configuration like v2 doesn't work) + // https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/node-configuring-proxies.html + let rh; + const httpsProxy = process.env.HTTPS_PROXY; + if (httpsProxy?.startsWith('http')) { + logger.debug('Http proxy used for AWS SSM SDK (v3): ' + hideUrlPassword(httpsProxy)); + rh = new NodeHttpHandler({ + httpsAgent: new proxy(httpsProxy), + }); + } + const client = new SSM({ region: process.env.AWS_REGION, requestHandler: rh }); return (await client.getParameter({ Name: parameter_name, WithDecryption: true })).Parameter?.Value as string; } diff --git a/modules/runners/lambdas/runners/src/lambda.test.ts b/modules/runners/lambdas/runners/src/lambda.test.ts index 8fa3b9a6c5..af9344dc58 100644 --- a/modules/runners/lambdas/runners/src/lambda.test.ts +++ b/modules/runners/lambdas/runners/src/lambda.test.ts @@ -1,7 +1,8 @@ import { Context, SQSEvent, SQSRecord } from 'aws-lambda'; +import { config } from 'aws-sdk'; import { mocked } from 'jest-mock'; -import { adjustPool, scaleDownHandler, scaleUpHandler } from './lambda'; +import { adjustPool, configureProxyAwsSdkV2Only, scaleDownHandler, scaleUpHandler } from './lambda'; import { logger } from './logger'; import { adjust } from './pool/pool'; import ScaleError from './scale-runners/ScaleError'; @@ -157,3 +158,31 @@ describe('Adjust pool.', () => { expect(logSpy).lastCalledWith(error); }); }); + +describe('Test proxy configuration for AWS SDK v2', () => { + beforeEach(() => { + // Remove any proxy if existing (from user/execution env or from previous test) + process.env.HTTPS_PROXY = undefined; + }); + + afterEach(() => { + // Remove any set proxy + process.env.HTTPS_PROXY = undefined; + + // Reset potential agent in AWS config + delete config.httpOptions?.agent; + }); + + it('Proxy configured', async () => { + process.env.HTTPS_PROXY = 'http://proxy.company.com'; + expect(configureProxyAwsSdkV2Only(config)).toBe(true); + const agent = config.httpOptions?.agent; + expect(agent).toBeDefined(); + expect(JSON.stringify(agent)).toContain(process.env.HTTPS_PROXY); + }); + + it('Proxy not configured', async () => { + expect(configureProxyAwsSdkV2Only(config)).toBe(false); + expect(config.httpOptions?.agent).toBeUndefined(); + }); +}); diff --git a/modules/runners/lambdas/runners/src/lambda.ts b/modules/runners/lambdas/runners/src/lambda.ts index 5e413a36a2..32758e11f4 100644 --- a/modules/runners/lambdas/runners/src/lambda.ts +++ b/modules/runners/lambdas/runners/src/lambda.ts @@ -1,4 +1,6 @@ import { Context, SQSEvent } from 'aws-lambda'; +import { config } from 'aws-sdk'; +import proxy from 'proxy-agent'; import 'source-map-support/register'; import { LogFields, logger } from './logger'; @@ -6,6 +8,7 @@ import { PoolEvent, adjust } from './pool/pool'; import ScaleError from './scale-runners/ScaleError'; import { scaleDown } from './scale-runners/scale-down'; import { scaleUp } from './scale-runners/scale-up'; +import { hideUrlPassword } from './utils/url'; export async function scaleUpHandler(event: SQSEvent, context: Context): Promise { logger.setSettings({ requestId: context.awsRequestId }); @@ -19,6 +22,7 @@ export async function scaleUpHandler(event: SQSEvent, context: Context): Promise } try { + configureProxyAwsSdkV2Only(config); await scaleUp(event.Records[0].eventSource, JSON.parse(event.Records[0].body)); } catch (e) { if (e instanceof ScaleError) { @@ -33,6 +37,7 @@ export async function scaleDownHandler(context: Context): Promise { logger.setSettings({ requestId: context.awsRequestId }); try { + configureProxyAwsSdkV2Only(config); await scaleDown(); } catch (e) { logger.error(e); @@ -48,3 +53,22 @@ export async function adjustPool(event: PoolEvent, context: Context): Promise SSM & EC2 in 'runners.ts' file ('ssm.ts' is already in aws-sdk v3) + * https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/node-configuring-proxies.html + * + * Configure proxy for AWS config in parameter, return true if updated. + */ +export function configureProxyAwsSdkV2Only(awsConfig: AWS.Config) { + const httpsProxy = process.env.HTTPS_PROXY; + if (httpsProxy?.startsWith('http')) { + logger.debug('Http proxy for AWS SDK (v2): ' + hideUrlPassword(httpsProxy)); + awsConfig.update({ + httpOptions: { agent: proxy(httpsProxy) }, + }); + return true; + } + return false; +} diff --git a/modules/runners/lambdas/runners/src/local-down.ts b/modules/runners/lambdas/runners/src/local-down.ts index 2d0ac05fca..d38e114153 100644 --- a/modules/runners/lambdas/runners/src/local-down.ts +++ b/modules/runners/lambdas/runners/src/local-down.ts @@ -1,10 +1,15 @@ +import { config } from 'aws-sdk'; + +import { configureProxyAwsSdkV2Only } from './lambda'; +import { logger } from './logger'; import { scaleDown } from './scale-runners/scale-down'; export function run(): void { + configureProxyAwsSdkV2Only(config); scaleDown() .then() .catch((e) => { - console.log(e); + logger.error(e); }); } diff --git a/modules/runners/lambdas/runners/src/local-pool.ts b/modules/runners/lambdas/runners/src/local-pool.ts index ab8c74a1a0..e5faf27b89 100644 --- a/modules/runners/lambdas/runners/src/local-pool.ts +++ b/modules/runners/lambdas/runners/src/local-pool.ts @@ -1,10 +1,15 @@ +import { config } from 'aws-sdk'; + +import { configureProxyAwsSdkV2Only } from './lambda'; +import { logger } from './logger'; import { adjust } from './pool/pool'; export function run(): void { + configureProxyAwsSdkV2Only(config); adjust({ poolSize: 1 }) .then() .catch((e) => { - console.log(e); + logger.error(e); }); } diff --git a/modules/runners/lambdas/runners/src/local.ts b/modules/runners/lambdas/runners/src/local.ts index 5c79110d76..d52ba60dbb 100644 --- a/modules/runners/lambdas/runners/src/local.ts +++ b/modules/runners/lambdas/runners/src/local.ts @@ -1,3 +1,6 @@ +import { config } from 'aws-sdk'; + +import { configureProxyAwsSdkV2Only } from './lambda'; import { logger } from './logger'; import { ActionRequestMessage, scaleUp } from './scale-runners/scale-up'; @@ -34,6 +37,7 @@ const sqsEvent = { }; export function run(): void { + configureProxyAwsSdkV2Only(config); scaleUp(sqsEvent.Records[0].eventSource, sqsEvent.Records[0].body as ActionRequestMessage) .then() .catch((e) => { diff --git a/modules/runners/lambdas/runners/src/utils/url.test.ts b/modules/runners/lambdas/runners/src/utils/url.test.ts new file mode 100644 index 0000000000..8ba8b7b4f3 --- /dev/null +++ b/modules/runners/lambdas/runners/src/utils/url.test.ts @@ -0,0 +1,12 @@ +import { hideUrlPassword } from './url'; + +describe('Test URL proxy validation', () => { + test('URL credentials port 80', async () => { + const url = hideUrlPassword('http://foo:bar@proxy.company.com:80'); + expect(url).toBe('http://foo:*****@proxy.company.com/'); + }); + test('URL port 8080', async () => { + const url = hideUrlPassword('http://proxy.company.com:8080'); + expect(url).toBe('http://proxy.company.com:8080/'); + }); +}); diff --git a/modules/runners/lambdas/runners/src/utils/url.ts b/modules/runners/lambdas/runners/src/utils/url.ts new file mode 100644 index 0000000000..ed9dfa1b12 --- /dev/null +++ b/modules/runners/lambdas/runners/src/utils/url.ts @@ -0,0 +1,7 @@ +export function hideUrlPassword(url: string): string { + const urlProxy = new URL(url); + if (urlProxy.password) { + urlProxy.password = '*****'; + } + return urlProxy.toString(); +} diff --git a/modules/runners/main.tf b/modules/runners/main.tf index 7fea4e8940..340e9623b2 100644 --- a/modules/runners/main.tf +++ b/modules/runners/main.tf @@ -132,6 +132,7 @@ resource "aws_launch_template" "runner" { start_runner = templatefile(local.userdata_start_runner[var.runner_os], {}) ghes_url = var.ghes_url ghes_ssl_verify = var.ghes_ssl_verify + http_proxy = var.http_proxy ## retain these for backwards compatibility environment = var.prefix enable_cloudwatch_agent = var.enable_cloudwatch_agent diff --git a/modules/runners/scale-down.tf b/modules/runners/scale-down.tf index 08181485c8..fddac8216f 100644 --- a/modules/runners/scale-down.tf +++ b/modules/runners/scale-down.tf @@ -32,6 +32,7 @@ resource "aws_lambda_function" "scale_down" { PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.github_app_parameters.key_base64.name RUNNER_BOOT_TIME_IN_MINUTES = var.runner_boot_time_in_minutes SCALE_DOWN_CONFIG = jsonencode(var.idle_config) + HTTPS_PROXY = var.http_proxy } } diff --git a/modules/runners/scale-up.tf b/modules/runners/scale-up.tf index 858224bf5a..f274513dce 100644 --- a/modules/runners/scale-up.tf +++ b/modules/runners/scale-up.tf @@ -22,6 +22,7 @@ resource "aws_lambda_function" "scale_up" { ENABLE_ORGANIZATION_RUNNERS = var.enable_organization_runners ENVIRONMENT = var.prefix GHES_URL = var.ghes_url + HTTPS_PROXY = var.http_proxy INSTANCE_ALLOCATION_STRATEGY = var.instance_allocation_strategy INSTANCE_MAX_SPOT_PRICE = var.instance_max_spot_price INSTANCE_TARGET_CAPACITY_TYPE = var.instance_target_capacity_type diff --git a/modules/runners/variables.tf b/modules/runners/variables.tf index 60c1d25e62..0b71d61ea1 100644 --- a/modules/runners/variables.tf +++ b/modules/runners/variables.tf @@ -407,6 +407,12 @@ variable "lambda_security_group_ids" { default = [] } +variable "http_proxy" { + description = "Http(s) proxy used by scale up/down runners Lambda to interact with AWS APIs (SSM, EC2), and usable in user data template. To use when `vpc_id` has no direct internet connection." + type = string + default = null +} + variable "key_name" { description = "Key pair name" type = string diff --git a/variables.tf b/variables.tf index 7c45e06179..e7560797f8 100644 --- a/variables.tf +++ b/variables.tf @@ -371,6 +371,12 @@ variable "lambda_security_group_ids" { default = [] } +variable "http_proxy" { + description = "Http(s) proxy used by scale up/down runners Lambda to interact with AWS APIs (SSM, EC2), and usable in user data template. To use when `vpc_id` has no direct internet connection." + type = string + default = null +} + variable "key_name" { description = "Key pair name" type = string