diff --git a/README.md b/README.md
index 8b04183055..8cde215df0 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@ This [Terraform](https://www.terraform.io/) module creates the required infrastr
- Tailored software, hardware and network configuration: Bring your own AMI, define the instance types and subnets to use.
- OS support: Linux (x64/arm64) and Windows
- Multi-Runner: Create multiple runner configurations with a single deployment
-- GitHub cloud and GitHub Enterprise Server (GHES) support.
+- GitHub cloud, Github Cloud with Data Residency and GitHub Enterprise Server (GHES) support.
- Org and repo level runners. enterprise level runners are not supported (yet).
@@ -140,7 +140,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh)
| [enable\_userdata](#input\_enable\_userdata) | Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI. | `bool` | `true` | no |
| [eventbridge](#input\_eventbridge) | Enable the use of EventBridge by the module. By enabling this feature events will be put on the EventBridge by the webhook instead of directly dispatching to queues for scaling.
`enable`: Enable the EventBridge feature.
`accept_events`: List can be used to only allow specific events to be putted on the EventBridge. By default all events, empty list will be be interpreted as all events. |
object({
enable = optional(bool, true)
accept_events = optional(list(string), null)
})
| `{}` | no |
| [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 |
+| [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB - github.com. However if you are using Github Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com | `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 |
| [idle\_config](#input\_idle\_config) | List of time periods, defined as a 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
evictionStrategy = optional(string, "oldest_first")
}))
| `[]` | no |
| [instance\_allocation\_strategy](#input\_instance\_allocation\_strategy) | The allocation strategy for spot instances. AWS recommends using `price-capacity-optimized` however the AWS default is `lowest-price`. | `string` | `"lowest-price"` | no |
diff --git a/docs/configuration.md b/docs/configuration.md
index be0ea03975..c7f53121ed 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -10,7 +10,7 @@ To be able to support a number of use-cases, the module has quite a lot of confi
- Linux vs Windows. You can configure the OS types linux and win. Linux will be used by default.
- Re-use vs Ephemeral. By default runners are re-used, until detected idle. Once idle they will be removed from the pool. To improve security we are introducing ephemeral runners. Those runners are only used for one job. Ephemeral runners only work in combination with the workflow job event. For ephemeral runners the lambda requests a JIT (just in time) configuration via the GitHub API to register the runner. [JIT configuration](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-just-in-time-runners) is limited to ephemeral runners (and currently not supported by GHES). For non-ephemeral runners, a registration token is always requested. In both cases the configuration is made available to the instance via the same SSM parameter. To disable JIT configuration for ephemeral runners set `enable_jit_config` to `false`. We also suggest using a pre-build AMI to improve the start time of jobs for ephemeral runners.
- Job retry (**Beta**). By default the scale-up lambda will discard the message when it is handled. Meaning in the ephemeral use-case an instance is created. The created runner will ask GitHub for a job, no guarantee it will run the job for which it was scaling. Result could be that with small system hick-up the job is keeping waiting for a runner. Enable a pool (org runners) is one option to avoid this problem. Another option is to enable the job retry function. Which will retry the job after a delay for a configured number of times.
-- GitHub Cloud vs GitHub Enterprise Server (GHES). The runners support GitHub Cloud as well GitHub Enterprise Server. For GHES, we rely on our community for support and testing. We have no capability to test GHES ourselves.
+- GitHub Cloud vs GitHub Enterprise Server (GHES). The runners support GitHub Cloud (Public GitHub - github.com), GitHub Data Residency instances (ghe.com), and GitHub Enterprise Server. For GHES, we rely on our community for support and testing. We have no capability to test GHES ourselves.
- Spot vs on-demand. The runners use either the EC2 spot or on-demand life cycle. Runners will be created via the AWS [CreateFleet API](https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_CreateFleet.html). The module (scale up lambda) will request via the CreateFleet API to create instances in one of the subnets and of the specified instance types.
- ARM64 support via Graviton/Graviton2 instance-types. When using the default example or top-level module, specifying `instance_types` that match a Graviton/Graviton 2 (ARM64) architecture (e.g. a1, t4g or any 6th-gen `g` or `gd` type), you must also specify `runner_architecture = "arm64"` and the sub-modules will be automatically configured to provision with ARM64 AMIs and leverage GitHub's ARM64 action runner. See below for more details.
- Disable default labels for the runners (os, architecture and `self-hosted`) can achieve by setting `runner_disable_default_labels` = true. If enabled, the runner will only have the extra labels provided in `runner_extra_labels`. In case you on own start script is used, this configuration parameter needs to be parsed via SSM.
diff --git a/lambdas/functions/control-plane/src/pool/pool.test.ts b/lambdas/functions/control-plane/src/pool/pool.test.ts
index a7ee7b9797..253d63300b 100644
--- a/lambdas/functions/control-plane/src/pool/pool.test.ts
+++ b/lambdas/functions/control-plane/src/pool/pool.test.ts
@@ -5,7 +5,7 @@ import nock from 'nock';
import { listEC2Runners } from '../aws/runners';
import * as ghAuth from '../github/auth';
-import { createRunners } from '../scale-runners/scale-up';
+import { createRunners, getGitHubEnterpriseApiUrl } from '../scale-runners/scale-up';
import { adjust } from './pool';
const mockOctokit = {
@@ -28,7 +28,7 @@ jest.mock('./../aws/runners', () => ({
listEC2Runners: jest.fn(),
}));
jest.mock('./../github/auth');
-jest.mock('./../scale-runners/scale-up');
+jest.mock('../scale-runners/scale-up');
const mocktokit = Octokit as jest.MockedClass;
const mockedAppAuth = mocked(ghAuth.createGithubAppAuth, {
@@ -167,6 +167,12 @@ beforeEach(() => {
describe('Test simple pool.', () => {
describe('With GitHub Cloud', () => {
+ beforeEach(() => {
+ (getGitHubEnterpriseApiUrl as jest.Mock).mockReturnValue({
+ ghesApiUrl: '',
+ ghesBaseUrl: '',
+ });
+ });
it('Top up pool with pool size 2 registered.', async () => {
await expect(await adjust({ poolSize: 3 })).resolves;
expect(createRunners).toHaveBeenCalledTimes(1);
@@ -240,7 +246,29 @@ describe('Test simple pool.', () => {
describe('With GHES', () => {
beforeEach(() => {
- process.env.GHES_URL = 'https://github.enterprise.something';
+ (getGitHubEnterpriseApiUrl as jest.Mock).mockReturnValue({
+ ghesApiUrl: 'https://api.github.enterprise.something',
+ ghesBaseUrl: 'https://github.enterprise.something',
+ });
+ });
+
+ it('Top up if the pool size is set to 5', async () => {
+ await expect(await adjust({ poolSize: 5 })).resolves;
+ // 2 idle, top up with 3 to match a pool of 5
+ expect(createRunners).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ numberOfRunners: 3 }),
+ expect.anything(),
+ );
+ });
+ });
+
+ describe('With Github Data Residency', () => {
+ beforeEach(() => {
+ (getGitHubEnterpriseApiUrl as jest.Mock).mockReturnValue({
+ ghesApiUrl: 'https://api.companyname.ghe.com',
+ ghesBaseUrl: 'https://companyname.ghe.com',
+ });
});
it('Top up if the pool size is set to 5', async () => {
diff --git a/lambdas/functions/control-plane/src/pool/pool.ts b/lambdas/functions/control-plane/src/pool/pool.ts
index 93f9d02257..162a7d0f6d 100644
--- a/lambdas/functions/control-plane/src/pool/pool.ts
+++ b/lambdas/functions/control-plane/src/pool/pool.ts
@@ -5,7 +5,7 @@ import yn from 'yn';
import { bootTimeExceeded, listEC2Runners } from '../aws/runners';
import { RunnerList } from '../aws/runners.d';
import { createGithubAppAuth, createGithubInstallationAuth, createOctokitClient } from '../github/auth';
-import { createRunners } from '../scale-runners/scale-up';
+import { createRunners, getGitHubEnterpriseApiUrl } from '../scale-runners/scale-up';
const logger = createChildLogger('pool');
@@ -24,7 +24,6 @@ export async function adjust(event: PoolEvent): Promise {
const runnerGroup = process.env.RUNNER_GROUP_NAME || '';
const runnerNamePrefix = process.env.RUNNER_NAME_PREFIX || '';
const environment = process.env.ENVIRONMENT;
- const ghesBaseUrl = process.env.GHES_URL;
const ssmTokenPath = process.env.SSM_TOKEN_PATH;
const ssmConfigPath = process.env.SSM_CONFIG_PATH || '';
const subnets = process.env.SUBNET_IDS.split(',');
@@ -43,10 +42,7 @@ export async function adjust(event: PoolEvent): Promise {
? (JSON.parse(process.env.ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS) as [string])
: [];
- let ghesApiUrl = '';
- if (ghesBaseUrl) {
- ghesApiUrl = `${ghesBaseUrl}/api/v3`;
- }
+ const { ghesApiUrl, ghesBaseUrl } = getGitHubEnterpriseApiUrl();
const installationId = await getInstallationId(ghesApiUrl, runnerOwner);
const ghAuth = await createGithubInstallationAuth(installationId, ghesApiUrl);
diff --git a/lambdas/functions/control-plane/src/scale-runners/job-retry.ts b/lambdas/functions/control-plane/src/scale-runners/job-retry.ts
index 61b296414e..bd9ebbd3b9 100644
--- a/lambdas/functions/control-plane/src/scale-runners/job-retry.ts
+++ b/lambdas/functions/control-plane/src/scale-runners/job-retry.ts
@@ -1,6 +1,6 @@
import { addPersistentContextToChildLogger, createSingleMetric, logger } from '@aws-github-runner/aws-powertools-util';
import { publishMessage } from '../aws/sqs';
-import { ActionRequestMessage, ActionRequestMessageRetry, getGitHubEnterpriseApiUrl, isJobQueued } from './scale-up';
+import { ActionRequestMessage, ActionRequestMessageRetry, isJobQueued, getGitHubEnterpriseApiUrl } from './scale-up';
import { getOctokit } from '../github/octokit';
import { MetricUnit } from '@aws-lambda-powertools/metrics';
import yn from 'yn';
diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts
index bfa2b5600f..8eb229e641 100644
--- a/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts
+++ b/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts
@@ -159,11 +159,11 @@ describe('Scale down runners', () => {
mockCreateClient.mockResolvedValue(new mocktokit());
});
- const endpoints = ['https://api.github.com', 'https://github.enterprise.something'];
+ const endpoints = ['https://api.github.com', 'https://github.enterprise.something', 'https://companyname.ghe.com'];
describe.each(endpoints)('for %s', (endpoint) => {
beforeEach(() => {
- if (endpoint.includes('enterprise')) {
+ if (endpoint.includes('enterprise') || endpoint.endsWith('.ghe.com')) {
process.env.GHES_URL = endpoint;
}
});
diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-down.ts b/lambdas/functions/control-plane/src/scale-runners/scale-down.ts
index be7183f929..10d5615b43 100644
--- a/lambdas/functions/control-plane/src/scale-runners/scale-down.ts
+++ b/lambdas/functions/control-plane/src/scale-runners/scale-down.ts
@@ -8,6 +8,7 @@ import { RunnerInfo, RunnerList } from './../aws/runners.d';
import { GhRunners, githubCache } from './cache';
import { ScalingDownConfig, getEvictionStrategy, getIdleRunnerCount } from './scale-down-config';
import { metricGitHubAppRateLimit } from '../github/rate-limit';
+import { getGitHubEnterpriseApiUrl } from './scale-up';
const logger = createChildLogger('scale-down');
@@ -21,11 +22,7 @@ async function getOrCreateOctokit(runner: RunnerInfo): Promise {
}
logger.debug(`[createGitHubClientForRunner] Cache miss for ${key}`);
- const ghesBaseUrl = process.env.GHES_URL;
- let ghesApiUrl = '';
- if (ghesBaseUrl) {
- ghesApiUrl = `${ghesBaseUrl}/api/v3`;
- }
+ const { ghesApiUrl } = getGitHubEnterpriseApiUrl();
const ghAuthPre = await createGithubAppAuth(undefined, ghesApiUrl);
const githubClientPre = await createOctokitClient(ghAuthPre.token, ghesApiUrl);
diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts
index 538b3c2aa1..917aac50c9 100644
--- a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts
+++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts
@@ -675,6 +675,310 @@ describe('scaleUp with public GH', () => {
});
});
+describe('scaleUp with Github Data Residency', () => {
+ beforeEach(() => {
+ process.env.GHES_URL = 'https://companyname.ghe.com';
+ });
+
+ it('ignores non-sqs events', async () => {
+ expect.assertions(1);
+ await expect(scaleUpModule.scaleUp('aws:s3', TEST_DATA)).rejects.toEqual(Error('Cannot handle non-SQS events!'));
+ });
+
+ it('checks queued workflows', async () => {
+ await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
+ 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.actions.getJobForWorkflowRun.mockImplementation(() => ({
+ data: { total_count: 0 },
+ }));
+ await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
+ expect(listEC2Runners).not.toBeCalled();
+ });
+
+ describe('on org level', () => {
+ beforeEach(() => {
+ process.env.ENABLE_ORGANIZATION_RUNNERS = 'true';
+ process.env.ENABLE_EPHEMERAL_RUNNERS = 'true';
+ process.env.RUNNER_NAME_PREFIX = 'unit-test-';
+ process.env.RUNNER_GROUP_NAME = 'Default';
+ process.env.SSM_CONFIG_PATH = '/github-action-runners/default/runners/config';
+ process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/config';
+ process.env.RUNNER_LABELS = 'label1,label2';
+
+ expectedRunnerParams = { ...EXPECTED_RUNNER_PARAMS };
+ mockSSMClient.reset();
+ });
+
+ it('gets the current org level runners', async () => {
+ await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
+ expect(listEC2Runners).toBeCalledWith({
+ environment: 'unit-test-environment',
+ runnerType: 'Org',
+ runnerOwner: TEST_DATA.repositoryOwner,
+ });
+ });
+
+ it('does not create a token when maximum runners has been reached', async () => {
+ process.env.RUNNERS_MAXIMUM_COUNT = '1';
+ process.env.ENABLE_EPHEMERAL_RUNNERS = 'false';
+ await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
+ expect(mockOctokit.actions.createRegistrationTokenForOrg).not.toBeCalled();
+ expect(mockOctokit.actions.createRegistrationTokenForRepo).not.toBeCalled();
+ });
+
+ it('does create a runner if maximum is set to -1', async () => {
+ process.env.RUNNERS_MAXIMUM_COUNT = '-1';
+ process.env.ENABLE_EPHEMERAL_RUNNERS = 'false';
+ await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
+ expect(listEC2Runners).not.toHaveBeenCalled();
+ expect(createRunner).toHaveBeenCalled();
+ });
+
+ it('creates a token when maximum runners has not been reached', async () => {
+ process.env.ENABLE_EPHEMERAL_RUNNERS = 'false';
+ await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
+ expect(mockOctokit.actions.createRegistrationTokenForOrg).toBeCalledWith({
+ org: TEST_DATA.repositoryOwner,
+ });
+ expect(mockOctokit.actions.createRegistrationTokenForRepo).not.toBeCalled();
+ });
+
+ it('creates a runner with correct config', async () => {
+ await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
+ expect(createRunner).toBeCalledWith(expectedRunnerParams);
+ });
+
+ it('creates a runner with labels in a specific group', async () => {
+ process.env.RUNNER_LABELS = 'label1,label2';
+ process.env.RUNNER_GROUP_NAME = 'TEST_GROUP';
+ await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
+ expect(createRunner).toBeCalledWith(expectedRunnerParams);
+ });
+
+ it('creates a runner with ami id override from ssm parameter', async () => {
+ process.env.AMI_ID_SSM_PARAMETER_NAME = 'my-ami-id-param';
+ await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
+ expect(createRunner).toBeCalledWith({ ...expectedRunnerParams, amiIdSsmParameterName: 'my-ami-id-param' });
+ });
+
+ it('Throws an error if runner group doesnt exist for ephemeral runners', async () => {
+ process.env.RUNNER_GROUP_NAME = 'test-runner-group';
+ mockSSMgetParameter.mockImplementation(async () => {
+ throw new Error('ParameterNotFound');
+ });
+ await expect(scaleUpModule.scaleUp('aws:sqs', TEST_DATA)).rejects.toBeInstanceOf(Error);
+ expect(mockOctokit.paginate).toHaveBeenCalledTimes(1);
+ });
+
+ it('Discards event if it is a User repo and org level runners is enabled', async () => {
+ process.env.ENABLE_ORGANIZATION_RUNNERS = 'true';
+ const USER_REPO_TEST_DATA = { ...TEST_DATA };
+ USER_REPO_TEST_DATA.repoOwnerType = 'User';
+ await scaleUpModule.scaleUp('aws:sqs', USER_REPO_TEST_DATA);
+ expect(createRunner).not.toHaveBeenCalled();
+ });
+
+ it('create SSM parameter for runner group id if it doesnt exist', async () => {
+ mockSSMgetParameter.mockImplementation(async () => {
+ throw new Error('ParameterNotFound');
+ });
+ await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
+ expect(mockOctokit.paginate).toHaveBeenCalledTimes(1);
+ expect(mockSSMClient).toHaveReceivedCommandTimes(PutParameterCommand, 2);
+ expect(mockSSMClient).toHaveReceivedNthSpecificCommandWith(1, PutParameterCommand, {
+ Name: `${process.env.SSM_CONFIG_PATH}/runner-group/${process.env.RUNNER_GROUP_NAME}`,
+ Value: '1',
+ Type: 'String',
+ });
+ });
+
+ it('Does not create SSM parameter for runner group id if it exists', async () => {
+ await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
+ expect(mockOctokit.paginate).toHaveBeenCalledTimes(0);
+ expect(mockSSMClient).toHaveReceivedCommandTimes(PutParameterCommand, 1);
+ });
+
+ it('create start runner config for ephemeral runners ', async () => {
+ process.env.RUNNERS_MAXIMUM_COUNT = '2';
+
+ await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
+ expect(mockOctokit.actions.generateRunnerJitconfigForOrg).toBeCalledWith({
+ org: TEST_DATA.repositoryOwner,
+ name: 'unit-test-i-12345',
+ runner_group_id: 1,
+ labels: ['label1', 'label2'],
+ });
+ expect(mockSSMClient).toHaveReceivedNthSpecificCommandWith(1, PutParameterCommand, {
+ Name: '/github-action-runners/default/runners/config/i-12345',
+ Value: 'TEST_JIT_CONFIG_ORG',
+ Type: 'SecureString',
+ Tags: [
+ {
+ Key: 'InstanceId',
+ Value: 'i-12345',
+ },
+ ],
+ });
+ });
+
+ it('create start runner config for non-ephemeral runners ', async () => {
+ process.env.ENABLE_EPHEMERAL_RUNNERS = 'false';
+ process.env.RUNNERS_MAXIMUM_COUNT = '2';
+ await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
+ expect(mockOctokit.actions.generateRunnerJitconfigForOrg).not.toBeCalled();
+ expect(mockOctokit.actions.createRegistrationTokenForOrg).toBeCalled();
+ expect(mockSSMClient).toHaveReceivedNthSpecificCommandWith(1, PutParameterCommand, {
+ Name: '/github-action-runners/default/runners/config/i-12345',
+ Value:
+ '--url https://companyname.ghe.com/Codertocat --token 1234abcd ' +
+ '--labels label1,label2 --runnergroup Default',
+ Type: 'SecureString',
+ Tags: [
+ {
+ Key: 'InstanceId',
+ Value: 'i-12345',
+ },
+ ],
+ });
+ });
+ it.each(RUNNER_TYPES)(
+ 'calls create start runner config of 40' + ' instances (ssm rate limit condition) to test time delay ',
+ async (type: RunnerType) => {
+ process.env.ENABLE_EPHEMERAL_RUNNERS = type === 'ephemeral' ? 'true' : 'false';
+ process.env.RUNNERS_MAXIMUM_COUNT = '40';
+ mockCreateRunner.mockImplementation(async () => {
+ return instances;
+ });
+ mockListRunners.mockImplementation(async () => {
+ return [];
+ });
+ const startTime = performance.now();
+ const instances = [
+ 'i-1234',
+ 'i-5678',
+ 'i-5567',
+ 'i-5569',
+ 'i-5561',
+ 'i-5560',
+ 'i-5566',
+ 'i-5536',
+ 'i-5526',
+ 'i-5516',
+ 'i-122',
+ 'i-123',
+ 'i-124',
+ 'i-125',
+ 'i-126',
+ 'i-127',
+ 'i-128',
+ 'i-129',
+ 'i-130',
+ 'i-131',
+ 'i-132',
+ 'i-133',
+ 'i-134',
+ 'i-135',
+ 'i-136',
+ 'i-137',
+ 'i-138',
+ 'i-139',
+ 'i-140',
+ 'i-141',
+ 'i-142',
+ 'i-143',
+ 'i-144',
+ 'i-145',
+ 'i-146',
+ 'i-147',
+ 'i-148',
+ 'i-149',
+ 'i-150',
+ 'i-151',
+ ];
+ await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
+ const endTime = performance.now();
+ expect(endTime - startTime).toBeGreaterThan(1000);
+ expect(mockSSMClient).toHaveReceivedCommandTimes(PutParameterCommand, 40);
+ },
+ 10000,
+ );
+ });
+ describe('on repo level', () => {
+ beforeEach(() => {
+ process.env.ENABLE_ORGANIZATION_RUNNERS = 'false';
+ process.env.RUNNER_NAME_PREFIX = 'unit-test';
+ expectedRunnerParams = { ...EXPECTED_RUNNER_PARAMS };
+ expectedRunnerParams.runnerType = 'Repo';
+ expectedRunnerParams.runnerOwner = `${TEST_DATA.repositoryOwner}/${TEST_DATA.repositoryName}`;
+ // `--url https://companyname.ghe.com${TEST_DATA.repositoryOwner}/${TEST_DATA.repositoryName}`,
+ // `--token 1234abcd`,
+ // ];
+ });
+
+ it('gets the current repo level runners', async () => {
+ await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
+ expect(listEC2Runners).toBeCalledWith({
+ environment: 'unit-test-environment',
+ runnerType: 'Repo',
+ runnerOwner: `${TEST_DATA.repositoryOwner}/${TEST_DATA.repositoryName}`,
+ });
+ });
+
+ it('does not create a token when maximum runners has been reached', async () => {
+ process.env.RUNNERS_MAXIMUM_COUNT = '1';
+ await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
+ expect(mockOctokit.actions.createRegistrationTokenForOrg).not.toBeCalled();
+ expect(mockOctokit.actions.createRegistrationTokenForRepo).not.toBeCalled();
+ });
+
+ it('creates a token when maximum runners has not been reached', async () => {
+ process.env.ENABLE_EPHEMERAL_RUNNERS = 'false';
+ await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
+ expect(mockOctokit.actions.createRegistrationTokenForOrg).not.toBeCalled();
+ expect(mockOctokit.actions.createRegistrationTokenForRepo).toBeCalledWith({
+ owner: TEST_DATA.repositoryOwner,
+ repo: TEST_DATA.repositoryName,
+ });
+ });
+
+ it('uses the default runner max count', async () => {
+ process.env.RUNNERS_MAXIMUM_COUNT = undefined;
+ await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
+ expect(mockOctokit.actions.createRegistrationTokenForRepo).toBeCalledWith({
+ owner: TEST_DATA.repositoryOwner,
+ repo: TEST_DATA.repositoryName,
+ });
+ });
+
+ it('creates a runner with correct config and labels', async () => {
+ process.env.RUNNER_LABELS = 'label1,label2';
+ await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
+ expect(createRunner).toBeCalledWith(expectedRunnerParams);
+ });
+
+ it('creates a runner and ensure the group argument is ignored', async () => {
+ process.env.RUNNER_LABELS = 'label1,label2';
+ process.env.RUNNER_GROUP_NAME = 'TEST_GROUP_IGNORED';
+ await scaleUpModule.scaleUp('aws:sqs', TEST_DATA);
+ expect(createRunner).toBeCalledWith(expectedRunnerParams);
+ });
+
+ it('Check error is thrown', async () => {
+ const mockCreateRunners = mocked(createRunner);
+ mockCreateRunners.mockRejectedValue(new Error('no retry'));
+ await expect(scaleUpModule.scaleUp('aws:sqs', TEST_DATA)).rejects.toThrow('no retry');
+ mockCreateRunners.mockReset();
+ });
+ });
+});
+
function defaultOctokitMockImpl() {
mockOctokit.actions.getJobForWorkflowRun.mockImplementation(() => ({
data: {
diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up.ts
index 9b00af3e48..08d16d682a 100644
--- a/lambdas/functions/control-plane/src/scale-runners/scale-up.ts
+++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.ts
@@ -355,8 +355,17 @@ export function getGitHubEnterpriseApiUrl() {
const ghesBaseUrl = process.env.GHES_URL;
let ghesApiUrl = '';
if (ghesBaseUrl) {
- ghesApiUrl = `${ghesBaseUrl}/api/v3`;
+ const url = new URL(ghesBaseUrl);
+ const domain = url.hostname;
+ if (domain.endsWith('.ghe.com')) {
+ // Data residency: Prepend 'api.'
+ ghesApiUrl = `https://api.${domain}`;
+ } else {
+ // GitHub Enterprise Server: Append '/api/v3'
+ ghesApiUrl = `${ghesBaseUrl}/api/v3`;
+ }
}
+ logger.debug(`Github Enterprise URLs: api_url - ${ghesApiUrl}; base_url - ${ghesBaseUrl}`);
return { ghesApiUrl, ghesBaseUrl };
}
diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md
index 5eca0b57e3..de8811bc49 100644
--- a/modules/multi-runner/README.md
+++ b/modules/multi-runner/README.md
@@ -130,7 +130,7 @@ module "multi-runner" {
| [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Enabling the default managed security group creation. Unmanaged security groups can be specified via `runner_additional_security_group_ids`. | `bool` | `true` | no |
| [eventbridge](#input\_eventbridge) | Enable the use of EventBridge by the module. By enabling this feature events will be put on the EventBridge by the webhook instead of directly dispatching to queues for scaling. | object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
})
| `{}` | no |
| [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 |
+| [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB.However if you are using Github Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com| `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 |
| [instance\_profile\_path](#input\_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\_termination\_watcher](#input\_instance\_termination\_watcher) | Configuration for the spot termination watcher lambda function. This feature is Beta, changes will not trigger a major release as long in beta.
`enable`: Enable or disable the spot termination watcher.
`memory_size`: Memory size linit in MB of the lambda.
`s3_key`: S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas.
`s3_object_version`: S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket.
`timeout`: Time out of the lambda in seconds.
`zip`: File location of the lambda zip file. | object({
enable = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
memory_size = optional(number, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
timeout = optional(number, null)
zip = optional(string, null)
})
| `{}` | no |
diff --git a/modules/multi-runner/variables.tf b/modules/multi-runner/variables.tf
index f962d1ea8c..8e4897985b 100644
--- a/modules/multi-runner/variables.tf
+++ b/modules/multi-runner/variables.tf
@@ -531,7 +531,7 @@ variable "key_name" {
}
variable "ghes_url" {
- description = "GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB"
+ description = "GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB. .However if you are using Github Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com|"
type = string
default = null
}
diff --git a/modules/runners/README.md b/modules/runners/README.md
index 151db327d7..a67ba5355b 100644
--- a/modules/runners/README.md
+++ b/modules/runners/README.md
@@ -159,7 +159,7 @@ yarn run dist
| [enable\_user\_data\_debug\_logging](#input\_enable\_user\_data\_debug\_logging) | Option to enable debug logging for user-data, this logs all secrets as well. | `bool` | `false` | no |
| [enable\_userdata](#input\_enable\_userdata) | Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI | `bool` | `true` | no |
| [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 |
+| [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. DO NOT SET IF USING PUBLIC GITHUB. However if you are using Github Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com | `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 |
| [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
evictionStrategy = optional(string, "oldest_first")
}))
| `[]` | 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 |
diff --git a/modules/runners/variables.tf b/modules/runners/variables.tf
index 65b2f930b6..fff6b17857 100644
--- a/modules/runners/variables.tf
+++ b/modules/runners/variables.tf
@@ -412,7 +412,7 @@ variable "runner_log_files" {
}
variable "ghes_url" {
- description = "GitHub Enterprise Server URL. DO NOT SET IF USING PUBLIC GITHUB"
+ description = "GitHub Enterprise Server URL. DO NOT SET IF USING PUBLIC GITHUB..However if you are using Github Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com|"
type = string
default = null
}
diff --git a/variables.tf b/variables.tf
index 5c57606edf..85c675d58a 100644
--- a/variables.tf
+++ b/variables.tf
@@ -455,7 +455,7 @@ variable "runner_log_files" {
}
variable "ghes_url" {
- description = "GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB"
+ description = "GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB. However if you are using Github Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com "
type = string
default = null
}