diff --git a/lambdas/functions/control-plane/package.json b/lambdas/functions/control-plane/package.json index f3a3481b50..e886920b1f 100644 --- a/lambdas/functions/control-plane/package.json +++ b/lambdas/functions/control-plane/package.json @@ -32,7 +32,7 @@ "dependencies": { "@aws-github-runner/aws-powertools-util": "*", "@aws-github-runner/aws-ssm-util": "*", - "@aws-github-runner/runner-provider": "*", + "@aws-github-runner/runner-providers": "*", "@aws-lambda-powertools/parameters": "^2.31.0", "@aws-sdk/client-ec2": "^3.1009.0", "@aws-sdk/client-sqs": "^3.1009.0", diff --git a/lambdas/functions/control-plane/src/control-plane-providers.ts b/lambdas/functions/control-plane/src/control-plane-providers.ts new file mode 100644 index 0000000000..d3dfbeeeee --- /dev/null +++ b/lambdas/functions/control-plane/src/control-plane-providers.ts @@ -0,0 +1,8 @@ +import { createControlPlaneProviderRegistry } from '@aws-github-runner/runner-providers/control-plane'; +import { runnerProviderTypes } from '@aws-github-runner/runner-providers/provider-types'; + +import { createStartRunnerConfig } from './scale-runners/github-runner'; + +export const controlPlaneProviderRegistry = createControlPlaneProviderRegistry(createStartRunnerConfig); + +export { runnerProviderTypes }; diff --git a/lambdas/functions/control-plane/src/pool/pool-contract.test.ts b/lambdas/functions/control-plane/src/pool/pool-contract.test.ts new file mode 100644 index 0000000000..e7c5eee21a --- /dev/null +++ b/lambdas/functions/control-plane/src/pool/pool-contract.test.ts @@ -0,0 +1,74 @@ +import type { Octokit } from '@octokit/rest'; +import type { RunnerProviderType } from '@aws-github-runner/runner-providers/provider-types'; +import { beforeEach, vi } from 'vitest'; + +import { definePoolContractTests } from '../test/runner-provider-contracts/pool'; +import { providerTypes } from '../test/runner-provider-contracts/provider-types'; +import * as ghAuth from '../github/auth'; +import { controlPlaneProviderRegistry } from '../control-plane-providers'; +import * as githubRunner from '../scale-runners/github-runner'; +import { adjust } from './pool'; +import type { PoolRunnerProvider } from './pool-provider'; + +vi.mock('../github/auth', () => ({ + createGithubAppAuth: vi.fn(), + createGithubInstallationAuth: vi.fn(), + createOctokitClient: vi.fn(), +})); + +vi.mock('../scale-runners/github-runner', () => ({ + createStartRunnerConfig: vi.fn(), + getGitHubEnterpriseApiUrl: vi.fn(), + validateSsmParameterStoreTags: vi.fn(), +})); + +const mockedAppAuth = vi.mocked(ghAuth.createGithubAppAuth); +const mockedInstallationAuth = vi.mocked(ghAuth.createGithubInstallationAuth); +const mockedCreateClient = vi.mocked(ghAuth.createOctokitClient); +const mockedResolveCapability = vi.spyOn(controlPlaneProviderRegistry, 'capability'); + +const githubClient = { + actions: { listSelfHostedRunnersForOrg: vi.fn() }, + apps: { getOrgInstallation: vi.fn() }, + paginate: vi.fn(), +} as unknown as Octokit; + +const cleanEnv = process.env; + +const lanes = providerTypes.map((type) => ({ + provider: { + type, + listRunners: vi.fn(), + countAvailableRunners: vi.fn(), + createRunners: vi.fn(), + } satisfies PoolRunnerProvider, +})); + +beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...cleanEnv }; + + mockedAppAuth.mockResolvedValue({ type: 'app', token: 'app-token', appId: 1, expiresAt: 'some-date' }); + mockedInstallationAuth.mockResolvedValue({ + type: 'token', + tokenType: 'installation', + token: 'installation-token', + createdAt: 'some-date', + expiresAt: 'some-date', + permissions: {}, + repositorySelection: 'selected', + installationId: 2, + }); + mockedCreateClient.mockResolvedValue(githubClient); + vi.mocked(githubRunner.getGitHubEnterpriseApiUrl).mockReturnValue({ ghesApiUrl: '', ghesBaseUrl: '' }); + vi.mocked(githubRunner.validateSsmParameterStoreTags).mockReturnValue([]); + vi.mocked(githubClient.apps.getOrgInstallation).mockResolvedValue({ data: { id: 2 } } as never); + vi.mocked(githubClient.paginate).mockResolvedValue([]); +}); + +definePoolContractTests({ + adjust, + githubInstallationClient: githubClient, + lanes, + resolveCapability: mockedResolveCapability, +}); diff --git a/lambdas/functions/control-plane/src/pool/pool-provider.ts b/lambdas/functions/control-plane/src/pool/pool-provider.ts index fa12b43a5e..9c5638a158 100644 --- a/lambdas/functions/control-plane/src/pool/pool-provider.ts +++ b/lambdas/functions/control-plane/src/pool/pool-provider.ts @@ -1,31 +1,6 @@ -import type { Octokit } from '@octokit/rest'; -import type { RunnerProvider } from '@aws-github-runner/runner-provider'; - -import type { CreateGitHubRunnerConfig, GitHubRunnerType } from '../scale-runners/types'; - -export interface RunnerStatus { - busy: boolean; - status: string; -} - -export interface ListPoolRunnersInput { - environment: string; - runnerOwner: string; - runnerType: GitHubRunnerType; -} - -export interface CreatePoolRunnersInput { - githubRunnerConfig: CreateGitHubRunnerConfig; - numberOfRunners: number; - githubInstallationClient: Octokit; -} - -export interface PoolRunnerProvider extends RunnerProvider { - listRunners(input: ListPoolRunnersInput): Promise; - countAvailableRunners( - runners: TRunner[], - runnerStatus: Map, - includeBusyRunners: boolean, - ): number; - createRunners(input: CreatePoolRunnersInput): Promise; -} +export type { + CreatePoolRunnersInput, + ListPoolRunnersInput, + PoolRunnerProvider, + RunnerStatus, +} from '@aws-github-runner/runner-providers/core'; diff --git a/lambdas/functions/control-plane/src/pool/pool.test.ts b/lambdas/functions/control-plane/src/pool/pool.test.ts index 643cbaffe4..3aa194bbd4 100644 --- a/lambdas/functions/control-plane/src/pool/pool.test.ts +++ b/lambdas/functions/control-plane/src/pool/pool.test.ts @@ -2,9 +2,9 @@ import { Octokit } from '@octokit/rest'; import moment from 'moment-timezone'; import * as nock from 'nock'; -import { listEC2Runners } from '../aws/ec2-runners'; +import { createRunners } from '@aws-github-runner/runner-providers/aws/ec2/control-plane/runner-config'; +import { listEC2Runners } from '@aws-github-runner/runner-providers/aws/ec2/control-plane/runners'; import * as ghAuth from '../github/auth'; -import { createRunners } from '../scale-runners/ec2'; import { getGitHubEnterpriseApiUrl } from '../scale-runners/github-runner'; import { adjust } from './pool'; import { describe, it, expect, beforeEach, vi, MockedClass } from 'vitest'; @@ -26,7 +26,7 @@ vi.mock('@octokit/rest', () => ({ }), })); -vi.mock('./../aws/ec2-runners', async () => ({ +vi.mock('@aws-github-runner/runner-providers/aws/ec2/control-plane/runners', async () => ({ listEC2Runners: vi.fn(), // Include any other functions from the module that might be used bootTimeExceeded: vi.fn(), @@ -37,12 +37,13 @@ vi.mock('./../github/auth', async () => ({ createOctokitClient: vi.fn(), })); -vi.mock('../scale-runners/ec2', async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock('@aws-github-runner/runner-providers/aws/ec2/control-plane/runner-config', async (importOriginal) => ({ + ...(await importOriginal()), createRunners: vi.fn(), })); vi.mock('../scale-runners/github-runner', async () => ({ + createStartRunnerConfig: vi.fn(), getGitHubEnterpriseApiUrl: vi.fn().mockReturnValue({ ghesApiUrl: '', ghesBaseUrl: '', @@ -206,6 +207,7 @@ describe('Test simple pool.', () => { expect.anything(), 1, expect.anything(), + expect.anything(), 'pool-lambda', ); }); @@ -223,6 +225,7 @@ describe('Test simple pool.', () => { expect.anything(), 8, expect.anything(), + expect.anything(), 'pool-lambda', ); }); @@ -234,6 +237,7 @@ describe('Test simple pool.', () => { expect.anything(), 8, expect.anything(), + expect.anything(), 'pool-lambda', ); }); @@ -323,6 +327,7 @@ describe('Test simple pool.', () => { expect.anything(), 3, expect.anything(), + expect.anything(), 'pool-lambda', ); }); @@ -344,6 +349,7 @@ describe('Test simple pool.', () => { expect.anything(), 3, expect.anything(), + expect.anything(), 'pool-lambda', ); }); @@ -400,6 +406,7 @@ describe('Test simple pool.', () => { expect.anything(), 1, expect.anything(), + expect.anything(), 'pool-lambda', ); }); @@ -437,6 +444,7 @@ describe('Test simple pool.', () => { expect.anything(), 2, expect.anything(), + expect.anything(), 'pool-lambda', ); }); @@ -451,6 +459,7 @@ describe('Test simple pool.', () => { expect.anything(), 1, expect.anything(), + expect.anything(), 'pool-lambda', ); }); @@ -464,6 +473,7 @@ describe('Test simple pool.', () => { expect.anything(), 8, expect.anything(), + expect.anything(), 'pool-lambda', ); }); @@ -488,6 +498,7 @@ describe('Test simple pool.', () => { expect.anything(), 2, expect.anything(), + expect.anything(), 'pool-lambda', ); }); diff --git a/lambdas/functions/control-plane/src/pool/pool.ts b/lambdas/functions/control-plane/src/pool/pool.ts index 5ef5fa897f..3a6ed45be9 100644 --- a/lambdas/functions/control-plane/src/pool/pool.ts +++ b/lambdas/functions/control-plane/src/pool/pool.ts @@ -1,10 +1,10 @@ import { Octokit } from '@octokit/rest'; import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; -import { resolveRunnerProviderType } from '@aws-github-runner/runner-provider'; +import { resolveRunnerProviderType } from '@aws-github-runner/runner-providers/provider-types'; import yn from 'yn'; import { createGithubAppAuth, createGithubInstallationAuth, createOctokitClient } from '../github/auth'; -import { createPoolRunnerProvider } from '../runner-provider-registry'; +import { controlPlaneProviderRegistry } from '../control-plane-providers'; import { getGitHubEnterpriseApiUrl, validateSsmParameterStoreTags } from '../scale-runners/github-runner'; import type { RunnerStatus } from './pool-provider'; @@ -17,7 +17,10 @@ export interface PoolEvent { export async function adjust(event: PoolEvent): Promise { const runnerProviderType = resolveRunnerProviderType(event.type); - const runnerProvider = createPoolRunnerProvider(runnerProviderType); + const runnerProvider = { + ...controlPlaneProviderRegistry.capability(runnerProviderType, 'pool')(), + type: runnerProviderType, + }; logger.info(`Checking current ${runnerProvider.type} pool size against pool of size: ${event.poolSize}`); const runnerLabels = process.env.RUNNER_LABELS || ''; const runnerGroup = process.env.RUNNER_GROUP_NAME || ''; diff --git a/lambdas/functions/control-plane/src/runner-provider-registry.test.ts b/lambdas/functions/control-plane/src/runner-provider-registry.test.ts deleted file mode 100644 index f22de57530..0000000000 --- a/lambdas/functions/control-plane/src/runner-provider-registry.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { createEc2PoolProvider } from './pool/ec2-pool'; -import type { PoolRunnerProvider } from './pool/pool-provider'; -import { - createPoolRunnerProvider, - createScaleDownRunnerProvider, - createScaleUpRunnerProvider, -} from './runner-provider-registry'; -import { createEc2ScaleDownProvider } from './scale-runners/ec2-scale-down'; -import { createEc2ScaleUpProvider } from './scale-runners/ec2-scale-up'; -import type { ScaleDownRunnerProvider } from './scale-runners/scale-down-provider'; -import type { ScaleUpRunnerProvider } from './scale-runners/scale-up-provider'; - -vi.mock('./pool/ec2-pool', () => ({ createEc2PoolProvider: vi.fn() })); -vi.mock('./scale-runners/ec2-scale-down', () => ({ createEc2ScaleDownProvider: vi.fn() })); -vi.mock('./scale-runners/ec2-scale-up', () => ({ createEc2ScaleUpProvider: vi.fn() })); - -const poolImplementation = { - listRunners: vi.fn(async () => []), - countAvailableRunners: vi.fn(() => 0), - createRunners: vi.fn(async () => []), -} satisfies Omit; - -const scaleUpImplementation = { - prepareGroup: vi.fn(async () => ({ runnerLabels: [], state: undefined })), - getCurrentRunners: vi.fn(async () => 0), - createRunners: vi.fn(async () => []), -} satisfies Omit; - -const scaleDownImplementation = { - list: vi.fn(async () => []), - bootTimeExceeded: vi.fn(() => false), - markOrphan: vi.fn(async () => undefined), - unmarkOrphan: vi.fn(async () => undefined), - terminate: vi.fn(async () => undefined), -} satisfies Omit; - -describe('runner provider registry', () => { - it('routes EC2 capabilities and injects the provider type', () => { - vi.mocked(createEc2PoolProvider).mockReturnValue(poolImplementation); - vi.mocked(createEc2ScaleUpProvider).mockReturnValue(scaleUpImplementation); - vi.mocked(createEc2ScaleDownProvider).mockReturnValue(scaleDownImplementation); - - expect(createPoolRunnerProvider('ec2')).toStrictEqual({ ...poolImplementation, type: 'ec2' }); - expect(createScaleUpRunnerProvider('ec2')).toStrictEqual({ ...scaleUpImplementation, type: 'ec2' }); - expect(createScaleDownRunnerProvider('ec2')).toStrictEqual({ ...scaleDownImplementation, type: 'ec2' }); - - expect(createEc2PoolProvider).toHaveBeenCalledTimes(1); - expect(createEc2ScaleUpProvider).toHaveBeenCalledTimes(1); - expect(createEc2ScaleDownProvider).toHaveBeenCalledTimes(1); - }); -}); diff --git a/lambdas/functions/control-plane/src/runner-provider-registry.ts b/lambdas/functions/control-plane/src/runner-provider-registry.ts deleted file mode 100644 index 95c342ebdf..0000000000 --- a/lambdas/functions/control-plane/src/runner-provider-registry.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { RunnerProviderType } from '@aws-github-runner/runner-provider'; - -import { createEc2PoolProvider } from './pool/ec2-pool'; -import type { PoolRunnerProvider } from './pool/pool-provider'; -import { createEc2ScaleDownProvider } from './scale-runners/ec2-scale-down'; -import { createEc2ScaleUpProvider } from './scale-runners/ec2-scale-up'; -import type { ScaleDownRunnerProvider } from './scale-runners/scale-down-provider'; -import type { ScaleUpRunnerProvider } from './scale-runners/scale-up-provider'; - -interface RunnerProviderFactory { - pool: () => Omit; - scaleUp: () => Omit; - scaleDown: () => Omit; -} - -const runnerProviderFactories: Record = { - ec2: { - pool: createEc2PoolProvider, - scaleUp: createEc2ScaleUpProvider, - scaleDown: createEc2ScaleDownProvider, - }, -}; - -export function createPoolRunnerProvider(type: RunnerProviderType): PoolRunnerProvider { - return { ...runnerProviderFactories[type].pool(), type }; -} - -export function createScaleUpRunnerProvider(type: RunnerProviderType): ScaleUpRunnerProvider { - return { ...runnerProviderFactories[type].scaleUp(), type }; -} - -export function createScaleDownRunnerProvider(type: RunnerProviderType): ScaleDownRunnerProvider { - return { ...runnerProviderFactories[type].scaleDown(), type }; -} diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-down-contract.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-down-contract.test.ts new file mode 100644 index 0000000000..8e808aff17 --- /dev/null +++ b/lambdas/functions/control-plane/src/scale-runners/scale-down-contract.test.ts @@ -0,0 +1,34 @@ +import type { RunnerProviderType } from '@aws-github-runner/runner-providers/provider-types'; +import { beforeEach, vi } from 'vitest'; + +import { providerTypes } from '../test/runner-provider-contracts/provider-types'; +import { defineScaleDownContractTests } from '../test/runner-provider-contracts/scale-down'; +import { controlPlaneProviderRegistry } from '../control-plane-providers'; +import { scaleDown } from './scale-down'; +import type { ScaleDownRunnerProvider } from './scale-down-provider'; + +const mockedResolveCapability = vi.spyOn(controlPlaneProviderRegistry, 'capability'); + +const cleanEnv = process.env; + +const lanes = providerTypes.map((type) => ({ + provider: { + type, + list: vi.fn(), + bootTimeExceeded: vi.fn(), + markOrphan: vi.fn(), + unmarkOrphan: vi.fn(), + terminate: vi.fn(), + } satisfies ScaleDownRunnerProvider, +})); + +beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...cleanEnv }; +}); + +defineScaleDownContractTests({ + lanes, + resolveCapability: mockedResolveCapability, + scaleDown, +}); diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-down-provider.ts b/lambdas/functions/control-plane/src/scale-runners/scale-down-provider.ts index c4966201aa..54490e482c 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-down-provider.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-down-provider.ts @@ -1,26 +1,5 @@ -import type { RunnerProvider } from '@aws-github-runner/runner-provider'; - -export interface RunnerList { - id: string; - launchTime?: Date; - owner?: string; - type?: string; - repo?: string; - org?: string; - orphan?: boolean; - githubRunnerId?: string; - bypassRemoval?: boolean; -} - -export interface RunnerInfo extends RunnerList { - owner: string; - type: string; -} - -export interface ScaleDownRunnerProvider extends RunnerProvider { - list(environment: string, orphan?: boolean): Promise; - bootTimeExceeded(runner: RunnerInfo): boolean; - markOrphan(id: string): Promise; - unmarkOrphan(id: string): Promise; - terminate(id: string): Promise; -} +export type { + ScaleDownRunnerInfo as RunnerInfo, + ScaleDownRunnerList as RunnerList, + ScaleDownRunnerProvider, +} from '@aws-github-runner/runner-providers/core'; 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 b53feeee5a..c11c74112d 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 @@ -1,31 +1,31 @@ import moment from 'moment'; import { describe, expect, it } from 'vitest'; -import { RunnerInfo } from '../aws/ec2-runners.d'; import { newestFirstStrategy, oldestFirstStrategy } from './scale-down'; +import type { RunnerInfo } from './scale-down-provider'; describe('When runners are sorted', () => { const runners: RunnerInfo[] = [ { - instanceId: '1', + id: '1', launchTime: moment(new Date()).subtract(1, 'minute').toDate(), owner: 'owner', type: 'type', }, { - instanceId: '3', + id: '3', launchTime: moment(new Date()).subtract(3, 'minute').toDate(), owner: 'owner', type: 'type', }, { - instanceId: '2', + id: '2', launchTime: moment(new Date()).subtract(2, 'minute').toDate(), owner: 'owner', type: 'type', }, { - instanceId: '0', + id: '0', launchTime: moment(new Date()).subtract(0, 'minute').toDate(), owner: 'owner', type: 'type', @@ -34,31 +34,31 @@ describe('When runners are sorted', () => { it('Should sort runners descending for eviction strategy oldest first te keep the youngest.', () => { runners.sort(oldestFirstStrategy); - expect(runners[0].instanceId).toEqual('0'); - expect(runners[1].instanceId).toEqual('1'); - expect(runners[2].instanceId).toEqual('2'); - expect(runners[3].instanceId).toEqual('3'); + expect(runners[0].id).toEqual('0'); + expect(runners[1].id).toEqual('1'); + expect(runners[2].id).toEqual('2'); + expect(runners[3].id).toEqual('3'); }); it('Should sort runners ascending for eviction strategy newest first te keep oldest.', () => { runners.sort(newestFirstStrategy); - expect(runners[0].instanceId).toEqual('3'); - expect(runners[1].instanceId).toEqual('2'); - expect(runners[2].instanceId).toEqual('1'); - expect(runners[3].instanceId).toEqual('0'); + expect(runners[0].id).toEqual('3'); + expect(runners[1].id).toEqual('2'); + expect(runners[2].id).toEqual('1'); + expect(runners[3].id).toEqual('0'); }); it('Should sort runners with equal launch time.', () => { const runnersTest = [...runners]; const same = moment(new Date()).subtract(4, 'minute').toDate(); runnersTest.push({ - instanceId: '4', + id: '4', launchTime: same, owner: 'owner', type: 'type', }); runnersTest.push({ - instanceId: '5', + id: '5', launchTime: same, owner: 'owner', type: 'type', @@ -77,19 +77,19 @@ describe('When runners are sorted', () => { it('Should sort runners even when launch time is undefined.', () => { const runnersTest = [ { - instanceId: '0', + id: '0', launchTime: undefined, owner: 'owner', type: 'type', }, { - instanceId: '1', + id: '1', launchTime: moment(new Date()).subtract(3, 'minute').toDate(), owner: 'owner', type: 'type', }, { - instanceId: '0', + id: '0', launchTime: undefined, owner: 'owner', type: 'type', 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 fdadc9fcc0..fb2acef72e 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-down.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-down.ts @@ -2,11 +2,11 @@ import { Octokit } from '@octokit/rest'; import { Endpoints } from '@octokit/types'; import { RequestError } from '@octokit/request-error'; import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; -import { resolveRunnerProviderType } from '@aws-github-runner/runner-provider'; +import { resolveRunnerProviderType } from '@aws-github-runner/runner-providers/provider-types'; import moment from 'moment'; import { createGithubAppAuth, createGithubInstallationAuth, createOctokitClient } from '../github/auth'; -import { createScaleDownRunnerProvider } from '../runner-provider-registry'; +import { controlPlaneProviderRegistry } from '../control-plane-providers'; import { GhRunners, githubCache } from './cache'; import { ScalingDownConfigList, getEvictionStrategy, getIdleRunnerCount } from './scale-down-config'; import { metricGitHubAppRateLimit } from '../github/rate-limit'; @@ -354,7 +354,10 @@ export async function scaleDown(): Promise { const environment = process.env.ENVIRONMENT; const scaleDownConfigs = JSON.parse(process.env.SCALE_DOWN_CONFIG) as ScalingDownConfigList; const runnerProviderType = resolveRunnerProviderType(process.env.RUNNER_PROVIDER_TYPE); - const runnerProvider = createScaleDownRunnerProvider(runnerProviderType); + const runnerProvider = { + ...controlPlaneProviderRegistry.capability(runnerProviderType, 'scaleDown')(), + type: runnerProviderType, + }; // first runners marked to be orphan. await terminateOrphan(environment, runnerProvider); diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up-contract.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up-contract.test.ts new file mode 100644 index 0000000000..bad5ff475f --- /dev/null +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up-contract.test.ts @@ -0,0 +1,84 @@ +import type { Octokit } from '@octokit/rest'; +import { beforeEach, vi } from 'vitest'; + +import { providerTypes } from '../test/runner-provider-contracts/provider-types'; +import { defineScaleUpContractTests } from '../test/runner-provider-contracts/scale-up'; +import * as ghAuth from '../github/auth'; +import { controlPlaneProviderRegistry } from '../control-plane-providers'; +import * as githubRunner from './github-runner'; +import { scaleUp } from './scale-up'; +import type { ScaleUpRunnerProvider } from './scale-up-provider'; +import type { ActionRequestMessageSQS } from './types'; + +vi.mock('../github/auth', () => ({ + createGithubAppAuth: vi.fn(), + createGithubInstallationAuth: vi.fn(), + createOctokitClient: vi.fn(), +})); + +vi.mock('./github-runner', async (importOriginal) => ({ + ...(await importOriginal()), + getGitHubEnterpriseApiUrl: vi.fn(), + getInstallationId: vi.fn(), + isJobQueued: vi.fn(), +})); + +const mockedAppAuth = vi.mocked(ghAuth.createGithubAppAuth); +const mockedInstallationAuth = vi.mocked(ghAuth.createGithubInstallationAuth); +const mockedCreateClient = vi.mocked(ghAuth.createOctokitClient); +const mockedResolveCapability = vi.spyOn(controlPlaneProviderRegistry, 'capability'); + +const githubClient = {} as Octokit; + +const payloads: ActionRequestMessageSQS[] = [ + { + id: 1, + eventType: 'workflow_job', + repositoryName: 'hello-world', + repositoryOwner: 'Codertocat', + installationId: 2, + repoOwnerType: 'Organization', + messageId: 'foobar', + }, +]; + +const cleanEnv = process.env; + +const lanes = providerTypes.map((type) => ({ + provider: { + type, + prepareGroup: vi.fn(), + getCurrentRunners: vi.fn(), + createRunners: vi.fn(), + } satisfies ScaleUpRunnerProvider, + state: { lane: type }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...cleanEnv }; + + mockedAppAuth.mockResolvedValue({ type: 'app', token: 'app-token', appId: 1, expiresAt: 'some-date' }); + mockedInstallationAuth.mockResolvedValue({ + type: 'token', + tokenType: 'installation', + token: 'installation-token', + createdAt: 'some-date', + expiresAt: 'some-date', + permissions: {}, + repositorySelection: 'selected', + installationId: 2, + }); + mockedCreateClient.mockResolvedValue(githubClient); + vi.mocked(githubRunner.getGitHubEnterpriseApiUrl).mockReturnValue({ ghesApiUrl: '', ghesBaseUrl: '' }); + vi.mocked(githubRunner.getInstallationId).mockResolvedValue(2); + vi.mocked(githubRunner.isJobQueued).mockResolvedValue(true); +}); + +defineScaleUpContractTests({ + createPayloads: () => structuredClone(payloads), + githubInstallationClient: githubClient, + lanes, + resolveCapability: mockedResolveCapability, + scaleUp, +}); diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up-provider.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up-provider.ts index 52ee07d2b5..5121d80238 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up-provider.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up-provider.ts @@ -1,33 +1,7 @@ -import type { Octokit } from '@octokit/rest'; -import type { RunnerProvider } from '@aws-github-runner/runner-provider'; - -import type { CreateGitHubRunnerConfig, GitHubRunnerType } from './types'; - -export interface CurrentRunnersInput { - runnerType: GitHubRunnerType; - runnerOwner: string; -} - -export interface CreateScaleUpRunnersInput { - githubRunnerConfig: CreateGitHubRunnerConfig; - numberOfRunners: number; - githubInstallationClient: Octokit; - state: TState; -} - -export interface PreparedScaleUpRunnerGroup { - runnerLabels: string[]; - state: TState; -} - -export interface CreateScaleUpRunnersResult { - instances: string[]; - retryableErrorCount: number; - nonRetryableErrorCount: number; -} - -export interface ScaleUpRunnerProvider extends RunnerProvider { - prepareGroup(messageLabels: string[]): Promise>; - getCurrentRunners(state: TState, input: CurrentRunnersInput): Promise; - createRunners(input: CreateScaleUpRunnersInput): Promise; -} +export type { + CreateScaleUpRunnersInput, + CreateScaleUpRunnersResult, + CurrentRunnersInput, + PreparedScaleUpRunnerGroup, + ScaleUpRunnerProvider, +} from '@aws-github-runner/runner-providers/core'; 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 4b363f4e55..cdb8b1bb4a 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.ts @@ -1,10 +1,10 @@ import { addPersistentContextToChildLogger, createChildLogger } from '@aws-github-runner/aws-powertools-util'; -import { resolveRunnerProviderType } from '@aws-github-runner/runner-provider'; +import { resolveRunnerProviderType } from '@aws-github-runner/runner-providers/provider-types'; import { Octokit } from '@octokit/rest'; import yn from 'yn'; import { createGithubAppAuth, createGithubInstallationAuth, createOctokitClient } from '../github/auth'; -import { createScaleUpRunnerProvider } from '../runner-provider-registry'; +import { controlPlaneProviderRegistry } from '../control-plane-providers'; import { getGitHubEnterpriseApiUrl, getInstallationId, @@ -87,7 +87,10 @@ export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise = Omit & { type: TType }; + +export interface PoolContractLane { + provider: TestPoolProvider; +} + +interface PoolContractOptions { + adjust: (event: PoolEvent) => Promise; + githubInstallationClient: Octokit; + lanes: readonly PoolContractLane[]; + resolveCapability: MockInstance<(type: TType, capability: 'pool') => () => Omit, 'type'>>; +} + +export function definePoolContractTests({ + adjust, + githubInstallationClient, + lanes, + resolveCapability, +}: PoolContractOptions): void { + describe.each(lanes.map((lane) => [lane.provider.type, lane] as const))( + '%s pool orchestration contract', + (_, { provider }) => { + beforeEach(() => { + process.env.ENVIRONMENT = 'test-environment'; + process.env.RUNNER_OWNER = 'test-owner'; + process.env.RUNNERS_MAXIMUM_COUNT = '-1'; + + resolveCapability.mockReturnValue(() => provider); + vi.mocked(provider.listRunners).mockResolvedValue([{}, {}]); + vi.mocked(provider.countAvailableRunners).mockReturnValue(2); + vi.mocked(provider.createRunners).mockResolvedValue([]); + }); + + it('creates only the runners required to reach the requested pool size', async () => { + await adjust({ poolSize: 5, type: provider.type }); + + expect(resolveCapability).toHaveBeenCalledWith(provider.type, 'pool'); + expect(provider.listRunners).toHaveBeenCalledWith({ + environment: 'test-environment', + runnerOwner: 'test-owner', + runnerType: 'Org', + }); + expect(provider.createRunners).toHaveBeenCalledWith( + expect.objectContaining({ + githubInstallationClient, + numberOfRunners: 3, + }), + ); + }); + + it('does not create runners when the requested pool size is already available', async () => { + await adjust({ poolSize: 2, type: provider.type }); + + expect(provider.createRunners).not.toHaveBeenCalled(); + }); + + it('caps pool creation at the remaining maximum-runner headroom', async () => { + process.env.RUNNERS_MAXIMUM_COUNT = '4'; + + await adjust({ poolSize: 5, type: provider.type }); + + expect(provider.createRunners).toHaveBeenCalledWith(expect.objectContaining({ numberOfRunners: 2 })); + }); + + it('forwards whether busy runners count toward the pool', async () => { + process.env.INCLUDE_BUSY_RUNNERS = 'true'; + + await adjust({ poolSize: 2, type: provider.type }); + + expect(provider.countAvailableRunners).toHaveBeenCalledWith(expect.any(Array), expect.any(Map), true); + }); + }, + ); +} diff --git a/lambdas/functions/control-plane/src/test/runner-provider-contracts/provider-types.ts b/lambdas/functions/control-plane/src/test/runner-provider-contracts/provider-types.ts new file mode 100644 index 0000000000..6a97a2cc2b --- /dev/null +++ b/lambdas/functions/control-plane/src/test/runner-provider-contracts/provider-types.ts @@ -0,0 +1,3 @@ +import { runnerProviderTypes } from '../../control-plane-providers'; + +export const providerTypes = runnerProviderTypes; diff --git a/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-down.ts b/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-down.ts new file mode 100644 index 0000000000..01a5ba3cca --- /dev/null +++ b/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-down.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; + +import type { ScaleDownRunnerProvider } from '../../scale-runners/scale-down-provider'; + +type TestScaleDownProvider = Omit & { type: TType }; + +export interface ScaleDownContractLane { + provider: TestScaleDownProvider; +} + +interface ScaleDownContractOptions { + lanes: readonly ScaleDownContractLane[]; + resolveCapability: MockInstance< + (type: TType, capability: 'scaleDown') => () => Omit, 'type'> + >; + scaleDown: () => Promise; +} + +export function defineScaleDownContractTests({ + lanes, + resolveCapability, + scaleDown, +}: ScaleDownContractOptions): void { + describe.each(lanes.map((lane) => [lane.provider.type, lane] as const))( + '%s scale-down orchestration contract', + (_, { provider }) => { + beforeEach(() => { + process.env.ENVIRONMENT = 'test-environment'; + process.env.RUNNER_PROVIDER_TYPE = provider.type; + process.env.SCALE_DOWN_CONFIG = '[]'; + + resolveCapability.mockReturnValue(() => provider); + vi.mocked(provider.list).mockResolvedValue([]); + vi.mocked(provider.bootTimeExceeded).mockReturnValue(false); + vi.mocked(provider.markOrphan).mockResolvedValue(); + vi.mocked(provider.unmarkOrphan).mockResolvedValue(); + vi.mocked(provider.terminate).mockResolvedValue(); + }); + + it('uses the provider to inspect orphan and active runners', async () => { + await scaleDown(); + + expect(resolveCapability).toHaveBeenCalledWith(provider.type, 'scaleDown'); + expect(provider.list).toHaveBeenNthCalledWith(1, 'test-environment', true); + expect(provider.list).toHaveBeenNthCalledWith(2, 'test-environment'); + }); + + it('terminates an orphan that has no GitHub runner identity', async () => { + vi.mocked(provider.list) + .mockResolvedValueOnce([{ id: 'orphan-runner', orphan: true }]) + .mockResolvedValue([]); + + await scaleDown(); + + expect(provider.terminate).toHaveBeenCalledWith('orphan-runner'); + }); + + it('does not terminate an orphan with bypass removal enabled', async () => { + vi.mocked(provider.list) + .mockResolvedValueOnce([{ id: 'protected-runner', orphan: true, bypassRemoval: true }]) + .mockResolvedValue([]); + + await scaleDown(); + + expect(provider.terminate).not.toHaveBeenCalled(); + }); + }, + ); +} diff --git a/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-up.ts b/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-up.ts new file mode 100644 index 0000000000..dddccbcf01 --- /dev/null +++ b/lambdas/functions/control-plane/src/test/runner-provider-contracts/scale-up.ts @@ -0,0 +1,93 @@ +import type { Octokit } from '@octokit/rest'; +import { beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; + +import type { ScaleUpRunnerProvider } from '../../scale-runners/scale-up-provider'; +import type { ActionRequestMessageSQS } from '../../scale-runners/types'; + +type TestScaleUpProvider = Omit & { type: TType }; + +export interface ScaleUpContractLane { + provider: TestScaleUpProvider; + state: unknown; +} + +interface ScaleUpContractOptions { + createPayloads: () => ActionRequestMessageSQS[]; + githubInstallationClient: Octokit; + lanes: readonly ScaleUpContractLane[]; + resolveCapability: MockInstance< + (type: TType, capability: 'scaleUp') => () => Omit, 'type'> + >; + scaleUp: (payloads: ActionRequestMessageSQS[]) => Promise; +} + +const createResult = { + instances: ['runner-1'], + retryableErrorCount: 0, + nonRetryableErrorCount: 0, +}; + +export function defineScaleUpContractTests({ + createPayloads, + githubInstallationClient, + lanes, + resolveCapability, + scaleUp, +}: ScaleUpContractOptions): void { + describe.each(lanes.map((lane) => [lane.provider.type, lane] as const))( + '%s scale-up orchestration contract', + (_, { provider, state }) => { + beforeEach(() => { + process.env.ENABLE_ORGANIZATION_RUNNERS = 'true'; + process.env.RUNNERS_MAXIMUM_COUNT = '3'; + process.env.RUNNER_PROVIDER_TYPE = provider.type; + + resolveCapability.mockReturnValue(() => provider); + vi.mocked(provider.prepareGroup).mockResolvedValue({ runnerLabels: [], state }); + vi.mocked(provider.getCurrentRunners).mockResolvedValue(0); + vi.mocked(provider.createRunners).mockResolvedValue(createResult); + }); + + it('forwards the prepared lane state through runner lookup and creation', async () => { + const payloads = createPayloads(); + payloads[0].labels = ['lane-label']; + + await scaleUp(payloads); + + expect(resolveCapability).toHaveBeenCalledWith(provider.type, 'scaleUp'); + expect(provider.prepareGroup).toHaveBeenCalledWith(['lane-label']); + expect(provider.getCurrentRunners).toHaveBeenCalledWith(state, { + runnerOwner: payloads[0].repositoryOwner, + runnerType: 'Org', + }); + expect(provider.createRunners).toHaveBeenCalledWith( + expect.objectContaining({ + githubInstallationClient, + numberOfRunners: 1, + state, + }), + ); + }); + + it('does not query current runners when the lane has unlimited capacity', async () => { + process.env.RUNNERS_MAXIMUM_COUNT = '-1'; + const payloads = createPayloads(); + payloads.push({ ...payloads[0], id: 2, messageId: 'message-2' }); + + await scaleUp(payloads); + + expect(provider.getCurrentRunners).not.toHaveBeenCalled(); + expect(provider.createRunners).toHaveBeenCalledWith(expect.objectContaining({ numberOfRunners: 2 })); + }); + + it('does not create runners when the lane has reached maximum capacity', async () => { + process.env.RUNNERS_MAXIMUM_COUNT = '1'; + vi.mocked(provider.getCurrentRunners).mockResolvedValue(1); + + await scaleUp(createPayloads()); + + expect(provider.createRunners).not.toHaveBeenCalled(); + }); + }, + ); +} diff --git a/lambdas/functions/control-plane/tsconfig.json b/lambdas/functions/control-plane/tsconfig.json index 30cbbee83e..3b30c17b7e 100644 --- a/lambdas/functions/control-plane/tsconfig.json +++ b/lambdas/functions/control-plane/tsconfig.json @@ -1,9 +1,5 @@ { - "extends" : "../../tsconfig.json", - "include": [ - "src/**/*" - ], - "exclude": [ - "src/**/*.test.ts" - ] + "extends": "../../tsconfig.json", + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts", "src/test/**/*"] } diff --git a/lambdas/functions/control-plane/vitest.config.ts b/lambdas/functions/control-plane/vitest.config.ts index 7abf8c629d..26601c12e1 100644 --- a/lambdas/functions/control-plane/vitest.config.ts +++ b/lambdas/functions/control-plane/vitest.config.ts @@ -8,7 +8,7 @@ export default mergeConfig(defaultConfig, { setupFiles: [resolve(__dirname, '../../aws-vitest-setup.ts')], coverage: { include: ['src/**/*.ts'], - exclude: ['src/**/*.test.ts', 'src/**/*.d.ts'], + exclude: ['src/**/*.test.ts', 'src/test/**/*', 'src/**/*.d.ts'], thresholds: { statements: 96.64, branches: 96.43, diff --git a/lambdas/functions/webhook/package.json b/lambdas/functions/webhook/package.json index edad66df81..f6bd1898ba 100644 --- a/lambdas/functions/webhook/package.json +++ b/lambdas/functions/webhook/package.json @@ -30,7 +30,7 @@ "dependencies": { "@aws-github-runner/aws-powertools-util": "*", "@aws-github-runner/aws-ssm-util": "*", - "@aws-github-runner/runner-provider": "*", + "@aws-github-runner/runner-providers": "*", "@aws-sdk/client-sqs": "^3.1009.0", "@middy/core": "^6.4.5", "@octokit/rest": "22.0.1", diff --git a/lambdas/functions/webhook/src/runners/aws-dynamic-labels-policy.ts b/lambdas/functions/webhook/src/runners/aws-dynamic-labels-policy.ts index 61625bfb00..c23e7f66ad 100644 --- a/lambdas/functions/webhook/src/runners/aws-dynamic-labels-policy.ts +++ b/lambdas/functions/webhook/src/runners/aws-dynamic-labels-policy.ts @@ -1,15 +1 @@ -export interface AwsDynamicLabelsValueRule { - allowed?: string[]; - denied?: string[]; - max?: number | string; -} - -/** - * AWS dynamic labels policy schema. `blocked_keys` rejects keys outright; - * `restricted_keys` applies optional per-key value rules. Provider-specific - * evaluators decide which dynamic label prefixes the policy applies to. - */ -export interface AwsDynamicLabelsPolicy { - blocked_keys?: string[]; - restricted_keys?: Record; -} +export type { AwsDynamicLabelsPolicy, AwsDynamicLabelsValueRule } from '@aws-github-runner/runner-providers'; diff --git a/lambdas/functions/webhook/src/runners/aws-dynamic-labels-provider.ts b/lambdas/functions/webhook/src/runners/aws-dynamic-labels-provider.ts deleted file mode 100644 index bd2d8faa7a..0000000000 --- a/lambdas/functions/webhook/src/runners/aws-dynamic-labels-provider.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { RunnerProviderType } from '@aws-github-runner/runner-provider'; - -import type { RunnerMatcherConfig } from '../sqs'; - -export interface AwsDynamicLabelDispatchTarget { - queue: RunnerMatcherConfig; - labels: string[]; -} - -export interface SelectAwsDynamicLabelQueueInput { - queue: RunnerMatcherConfig; - nonGhrLabels: string[]; - sanitizedGhrLabels: string[]; -} - -export interface AwsDynamicLabelProviderStrategy { - type: RunnerProviderType; - selectQueue(input: SelectAwsDynamicLabelQueueInput): AwsDynamicLabelDispatchTarget | undefined; -} diff --git a/lambdas/functions/webhook/src/runners/aws-dynamic-labels.test.ts b/lambdas/functions/webhook/src/runners/aws-dynamic-labels.test.ts index 19e5256cb3..870aae8f65 100644 --- a/lambdas/functions/webhook/src/runners/aws-dynamic-labels.test.ts +++ b/lambdas/functions/webhook/src/runners/aws-dynamic-labels.test.ts @@ -1,4 +1,4 @@ -import type { RunnerProviderType } from '@aws-github-runner/runner-provider'; +import type { RunnerProviderType } from '@aws-github-runner/runner-providers/provider-types'; import { describe, expect, it } from 'vitest'; import type { RunnerMatcherConfig } from '../sqs'; diff --git a/lambdas/functions/webhook/src/runners/aws-dynamic-labels.ts b/lambdas/functions/webhook/src/runners/aws-dynamic-labels.ts index ccfb2e7462..7a05cd8cc8 100644 --- a/lambdas/functions/webhook/src/runners/aws-dynamic-labels.ts +++ b/lambdas/functions/webhook/src/runners/aws-dynamic-labels.ts @@ -1,31 +1,27 @@ import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; -import { normalizeRunnerProviderType } from '@aws-github-runner/runner-provider'; +import type { DynamicLabelDispatchTarget } from '@aws-github-runner/runner-providers'; +import { normalizeRunnerProviderType } from '@aws-github-runner/runner-providers/provider-types'; +import { webhookProviderRegistry } from '@aws-github-runner/runner-providers/webhook'; import type { RunnerMatcherConfig } from '../sqs'; -import { AwsDynamicLabelDispatchTarget, AwsDynamicLabelProviderStrategy } from './aws-dynamic-labels-provider'; -import { ec2DynamicLabelProviderStrategy } from './ec2-dynamic-labels'; const logger = createChildLogger('handler'); -const awsDynamicLabelProviderStrategies: AwsDynamicLabelProviderStrategy[] = [ec2DynamicLabelProviderStrategy]; - export function selectAwsDynamicLabelQueue( matches: RunnerMatcherConfig[], nonGhrLabels: string[], sanitizedGhrLabels: string[], -): AwsDynamicLabelDispatchTarget | undefined { +): DynamicLabelDispatchTarget | undefined { for (const queue of matches) { const provider = normalizeRunnerProviderType(queue.runnerProvider); - const strategy = provider - ? awsDynamicLabelProviderStrategies.find((strategy) => strategy.type === provider) - : undefined; + const dynamicLabels = provider ? webhookProviderRegistry.capability(provider, 'dynamicLabels') : undefined; - if (!strategy) { + if (!dynamicLabels) { logger.warn(`Queue ${queue.id} has unsupported runner provider '${provider ?? String(queue.runnerProvider)}'`); continue; } - const target = strategy.selectQueue({ queue, nonGhrLabels, sanitizedGhrLabels }); + const target = dynamicLabels.selectQueue({ queue, nonGhrLabels, sanitizedGhrLabels }); if (target) return target; } diff --git a/lambdas/functions/webhook/src/sqs/index.ts b/lambdas/functions/webhook/src/sqs/index.ts index ba805ce88c..40fe9f1e75 100644 --- a/lambdas/functions/webhook/src/sqs/index.ts +++ b/lambdas/functions/webhook/src/sqs/index.ts @@ -1,9 +1,8 @@ import { SQS, SendMessageCommandInput } from '@aws-sdk/client-sqs'; import { WorkflowJobEvent } from '@octokit/webhooks-types'; import { createChildLogger, getTracedAWSV3Client } from '@aws-github-runner/aws-powertools-util'; -import type { RunnerProviderType } from '@aws-github-runner/runner-provider'; -import type { AwsDynamicLabelsPolicy } from '../runners/aws-dynamic-labels-policy'; +export type { MatcherConfig, RunnerConfig, RunnerMatcherConfig } from '@aws-github-runner/runner-providers'; const logger = createChildLogger('sqs'); @@ -20,26 +19,6 @@ export interface ActionRequestMessage { labels?: string[]; } -export interface MatcherConfig { - labelMatchers: string[][]; - exactMatch: boolean; - bidirectionalLabelMatch?: boolean; - enableDynamicLabels?: boolean; - awsDynamicLabelsPolicy?: AwsDynamicLabelsPolicy | null; - // TODO: Remove this legacy compatibility field and fallback in the next release. - /** @deprecated Use awsDynamicLabelsPolicy. Retained while existing SSM configurations migrate. */ - ec2DynamicLabelsPolicy?: AwsDynamicLabelsPolicy | null; -} - -export type RunnerConfig = RunnerMatcherConfig[]; - -export interface RunnerMatcherConfig { - matcherConfig: MatcherConfig; - runnerProvider?: RunnerProviderType; - id: string; - arn: string; -} - export interface GithubWorkflowEvent { workflowJobEvent: WorkflowJobEvent; } diff --git a/lambdas/libs/runner-provider/package.json b/lambdas/libs/runner-provider/package.json deleted file mode 100644 index 6ccf3983d8..0000000000 --- a/lambdas/libs/runner-provider/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@aws-github-runner/runner-provider", - "version": "1.0.0", - "main": "src/index.ts", - "type": "module", - "license": "MIT", - "scripts": { - "test": "NODE_ENV=test nx test", - "test:watch": "NODE_ENV=test nx test --watch", - "lint": "eslint src", - "format": "prettier --write \"**/*.ts\"", - "format-check": "prettier --check \"**/*.ts\"", - "all": "yarn format && yarn lint && yarn test" - }, - "nx": { - "includedScripts": [ - "format", - "format-check", - "lint", - "all" - ] - } -} diff --git a/lambdas/libs/runner-provider/tsconfig.json b/lambdas/libs/runner-provider/tsconfig.json deleted file mode 100644 index 9d616eca0f..0000000000 --- a/lambdas/libs/runner-provider/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "include": ["src/**/*"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/lambdas/libs/runner-provider/vitest.config.ts b/lambdas/libs/runner-provider/vitest.config.ts deleted file mode 100644 index 5179569242..0000000000 --- a/lambdas/libs/runner-provider/vitest.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { mergeConfig } from 'vitest/config'; -import defaultConfig from '../../vitest.base.config'; - -export default mergeConfig(defaultConfig, { - test: { - coverage: { - include: ['src/**/*.ts'], - exclude: ['src/**/*.test.ts', 'src/**/*.d.ts'], - thresholds: { - statements: 100, - branches: 100, - functions: 100, - lines: 100, - }, - }, - }, -}); diff --git a/lambdas/libs/runner-providers/aws/ec2/control-plane.ts b/lambdas/libs/runner-providers/aws/ec2/control-plane.ts new file mode 100644 index 0000000000..ef07fec861 --- /dev/null +++ b/lambdas/libs/runner-providers/aws/ec2/control-plane.ts @@ -0,0 +1,25 @@ +import type { CreateStartRunnerConfig, RunnerProviderPlugin } from '../../core'; + +import type { ControlPlaneProviderCapabilities, ControlPlaneProviderModule } from '../../contracts'; +import type {} from './src/environment'; +import { createEc2PoolProvider } from './src/control-plane/pool'; +import { createEc2ScaleDownProvider } from './src/control-plane/scale-down'; +import { createEc2ScaleUpProvider } from './src/control-plane/scale-up'; + +export function createEc2ControlPlanePlugin( + createStartRunnerConfig: CreateStartRunnerConfig, +): RunnerProviderPlugin { + return { + type: 'ec2', + capabilities: { + pool: () => createEc2PoolProvider(createStartRunnerConfig), + scaleUp: () => createEc2ScaleUpProvider(createStartRunnerConfig), + scaleDown: createEc2ScaleDownProvider, + }, + }; +} + +export const provider = { + type: 'ec2', + createPlugin: createEc2ControlPlanePlugin, +} satisfies ControlPlaneProviderModule<'ec2'>; diff --git a/lambdas/functions/control-plane/src/scale-runners/ec2-labels.ts b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/dynamic-labels.ts similarity index 99% rename from lambdas/functions/control-plane/src/scale-runners/ec2-labels.ts rename to lambdas/libs/runner-providers/aws/ec2/src/control-plane/dynamic-labels.ts index 496b7dab46..d3e31a74f9 100644 --- a/lambdas/functions/control-plane/src/scale-runners/ec2-labels.ts +++ b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/dynamic-labels.ts @@ -32,7 +32,7 @@ import { } from '@aws-sdk/client-ec2'; import { getTracedAWSV3Client } from '@aws-github-runner/aws-powertools-util'; -import { Ec2OverrideConfig } from './../aws/ec2-runners.d'; +import { Ec2OverrideConfig } from './runners.d'; const EC2_OVERRIDE_LIST_VALUE_SEPARATOR = ';'; diff --git a/lambdas/functions/control-plane/src/pool/ec2-pool.test.ts b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/pool.test.ts similarity index 88% rename from lambdas/functions/control-plane/src/pool/ec2-pool.test.ts rename to lambdas/libs/runner-providers/aws/ec2/src/control-plane/pool.test.ts index 6054811a24..6fd21d6924 100644 --- a/lambdas/functions/control-plane/src/pool/ec2-pool.test.ts +++ b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/pool.test.ts @@ -1,9 +1,9 @@ -import { bootTimeExceeded } from '../aws/ec2-runners'; -import type { RunnerList } from '../aws/ec2-runners.d'; -import { calculateEc2PoolSize } from './ec2-pool'; +import { bootTimeExceeded } from './runners'; +import type { RunnerList } from './runners.d'; +import { calculateEc2PoolSize } from './pool'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('../aws/ec2-runners', () => ({ +vi.mock('./runners', () => ({ bootTimeExceeded: vi.fn(), })); diff --git a/lambdas/functions/control-plane/src/pool/ec2-pool.ts b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/pool.ts similarity index 74% rename from lambdas/functions/control-plane/src/pool/ec2-pool.ts rename to lambdas/libs/runner-providers/aws/ec2/src/control-plane/pool.ts index f4ba8827d4..6578e66496 100644 --- a/lambdas/functions/control-plane/src/pool/ec2-pool.ts +++ b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/pool.ts @@ -1,9 +1,14 @@ import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; - -import { bootTimeExceeded, listEC2Runners } from '../aws/ec2-runners'; -import type { RunnerList } from '../aws/ec2-runners.d'; -import { createRunners, loadEc2ProviderConfig } from '../scale-runners/ec2'; -import type { CreatePoolRunnersInput, ListPoolRunnersInput, PoolRunnerProvider, RunnerStatus } from './pool-provider'; +import type { + CreateStartRunnerConfig, + CreatePoolRunnersInput, + ListPoolRunnersInput, + PoolRunnerProvider, + RunnerStatus, +} from '../../../../core'; +import { createRunners, loadEc2ProviderConfig } from './runner-config'; +import { bootTimeExceeded, listEC2Runners } from './runners'; +import type { RunnerList } from './runners.d'; const logger = createChildLogger('pool'); @@ -20,11 +25,10 @@ async function listEc2PoolRunners({ }); } -async function createEc2PoolRunners({ - githubRunnerConfig, - numberOfRunners, - githubInstallationClient, -}: CreatePoolRunnersInput): Promise { +async function createEc2PoolRunners( + { githubRunnerConfig, numberOfRunners, githubInstallationClient }: CreatePoolRunnersInput, + createStartRunnerConfig: CreateStartRunnerConfig, +): Promise { const config = loadEc2ProviderConfig(); const { instances } = await createRunners( @@ -41,16 +45,19 @@ async function createEc2PoolRunners({ }, numberOfRunners, githubInstallationClient, + createStartRunnerConfig, 'pool-lambda', ); return instances; } -export function createEc2PoolProvider(): Omit { +export function createEc2PoolProvider( + createStartRunnerConfig: CreateStartRunnerConfig, +): Omit { return { listRunners: listEc2PoolRunners, countAvailableRunners: calculateEc2PoolSize, - createRunners: createEc2PoolRunners, + createRunners: (input) => createEc2PoolRunners(input, createStartRunnerConfig), }; } diff --git a/lambdas/functions/control-plane/src/scale-runners/ec2.ts b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/runner-config.ts similarity index 93% rename from lambdas/functions/control-plane/src/scale-runners/ec2.ts rename to lambdas/libs/runner-providers/aws/ec2/src/control-plane/runner-config.ts index 43c14ea55b..15139b72ab 100644 --- a/lambdas/functions/control-plane/src/scale-runners/ec2.ts +++ b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/runner-config.ts @@ -1,14 +1,18 @@ import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; +import type { + CreateGitHubRunnerConfig, + CreateScaleUpRunnersResult, + CreateStartRunnerConfig, + GitHubRunnerMetadata, + LambdaRunnerSource, + StartRunnerConfigOptions, +} from '../../../../core'; import { Octokit } from '@octokit/rest'; import type { Tag } from '@aws-sdk/client-ec2'; import yn from 'yn'; -import { createRunner, tag, terminateRunner } from '../aws/ec2-runners'; -import type { RunnerInputParameters } from '../aws/ec2-runners.d'; -import { createStartRunnerConfig } from './github-runner'; -import type { GitHubRunnerMetadata, StartRunnerConfigOptions } from './github-runner'; -import type { CreateScaleUpRunnersResult } from './scale-up-provider'; -import type { CreateGitHubRunnerConfig, LambdaRunnerSource } from './types'; +import { createRunner, tag, terminateRunner } from './runners'; +import type { RunnerInputParameters } from './runners.d'; const logger = createChildLogger('ec2-runners'); const RUNNER_LABELS_TAG_KEY = 'ghr:runner_labels'; @@ -61,6 +65,7 @@ export async function createRunners( ec2RunnerConfig: CreateEC2RunnerConfig, numberOfRunners: number, ghClient: Octokit, + createStartRunnerConfig: CreateStartRunnerConfig, source: LambdaRunnerSource = 'scale-up-lambda', ): Promise { let result: CreateScaleUpRunnersResult; diff --git a/lambdas/functions/control-plane/src/aws/ec2-runners.d.ts b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/runners.d.ts similarity index 96% rename from lambdas/functions/control-plane/src/aws/ec2-runners.d.ts rename to lambdas/libs/runner-providers/aws/ec2/src/control-plane/runners.d.ts index 5a1b1b5cdf..3332ccd246 100644 --- a/lambdas/functions/control-plane/src/aws/ec2-runners.d.ts +++ b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/runners.d.ts @@ -7,7 +7,7 @@ import { Placement, FleetBlockDeviceMappingRequest, } from '@aws-sdk/client-ec2'; -import type { LambdaRunnerSource } from '../scale-runners/types'; +import type { LambdaRunnerSource } from '../../../../core'; export type RunnerType = 'Org' | 'Repo'; diff --git a/lambdas/functions/control-plane/src/aws/ec2-runners.test.ts b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/runners.test.ts similarity index 99% rename from lambdas/functions/control-plane/src/aws/ec2-runners.test.ts rename to lambdas/libs/runner-providers/aws/ec2/src/control-plane/runners.test.ts index 2b8abbd03d..efda4e7327 100644 --- a/lambdas/functions/control-plane/src/aws/ec2-runners.test.ts +++ b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/runners.test.ts @@ -21,9 +21,9 @@ import { mockClient } from 'aws-sdk-client-mock'; import 'aws-sdk-client-mock-jest/vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createRunner, listEC2Runners, tag, terminateRunner, untag } from './ec2-runners'; -import type { Ec2OverrideConfig, RunnerInfo, RunnerInputParameters, RunnerType } from './ec2-runners.d'; -import type { LambdaRunnerSource } from '../scale-runners/types'; +import type { LambdaRunnerSource } from '../../../../core'; +import { createRunner, listEC2Runners, tag, terminateRunner, untag } from './runners'; +import type { Ec2OverrideConfig, RunnerInfo, RunnerInputParameters, RunnerType } from './runners.d'; process.env.AWS_REGION = 'eu-east-1'; const mockEC2Client = mockClient(EC2Client); diff --git a/lambdas/functions/control-plane/src/aws/ec2-runners.ts b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/runners.ts similarity index 99% rename from lambdas/functions/control-plane/src/aws/ec2-runners.ts rename to lambdas/libs/runner-providers/aws/ec2/src/control-plane/runners.ts index 432770c900..a4eacae064 100644 --- a/lambdas/functions/control-plane/src/aws/ec2-runners.ts +++ b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/runners.ts @@ -22,7 +22,7 @@ import { getTracedAWSV3Client, tracer } from '@aws-github-runner/aws-powertools- import { getParameter } from '@aws-github-runner/aws-ssm-util'; import moment from 'moment'; -import * as Runners from './ec2-runners.d'; +import * as Runners from './runners.d'; const logger = createChildLogger('runners'); diff --git a/lambdas/functions/control-plane/src/scale-runners/ec2-scale-down.test.ts b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/scale-down.test.ts similarity index 97% rename from lambdas/functions/control-plane/src/scale-runners/ec2-scale-down.test.ts rename to lambdas/libs/runner-providers/aws/ec2/src/control-plane/scale-down.test.ts index 04dae3e0ee..509cfc61ba 100644 --- a/lambdas/functions/control-plane/src/scale-runners/ec2-scale-down.test.ts +++ b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/scale-down.test.ts @@ -3,11 +3,11 @@ import { RequestError } from '@octokit/request-error'; import moment from 'moment'; import nock from 'nock'; -import { RunnerList } from '../aws/ec2-runners.d'; -import * as ghAuth from '../github/auth'; -import { listEC2Runners, terminateRunner, tag, untag } from './../aws/ec2-runners'; -import { githubCache } from './cache'; -import { scaleDown } from './scale-down'; +import * as ghAuth from '../../../../../../functions/control-plane/src/github/auth'; +import { githubCache } from '../../../../../../functions/control-plane/src/scale-runners/cache'; +import { scaleDown } from '../../../../../../functions/control-plane/src/scale-runners/scale-down'; +import { listEC2Runners, terminateRunner, tag, untag } from './runners'; +import { RunnerList } from './runners.d'; import { describe, it, expect, beforeEach, vi } from 'vitest'; const mockOctokit = { @@ -31,7 +31,7 @@ vi.mock('@octokit/rest', () => ({ }), })); -vi.mock('./../aws/ec2-runners', async (importOriginal) => { +vi.mock('./runners', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, @@ -41,13 +41,13 @@ vi.mock('./../aws/ec2-runners', async (importOriginal) => { listEC2Runners: vi.fn(), }; }); -vi.mock('./../github/auth', async () => ({ +vi.mock('../../../../../../functions/control-plane/src/github/auth', async () => ({ createGithubAppAuth: vi.fn(), createGithubInstallationAuth: vi.fn(), createOctokitClient: vi.fn(), })); -vi.mock('./cache', async () => ({ +vi.mock('../../../../../../functions/control-plane/src/scale-runners/cache', async () => ({ githubCache: { getRunner: vi.fn(), addRunner: vi.fn(), diff --git a/lambdas/functions/control-plane/src/scale-runners/ec2-scale-down.ts b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/scale-down.ts similarity index 84% rename from lambdas/functions/control-plane/src/scale-runners/ec2-scale-down.ts rename to lambdas/libs/runner-providers/aws/ec2/src/control-plane/scale-down.ts index 554ea375b7..a80a9db8f7 100644 --- a/lambdas/functions/control-plane/src/scale-runners/ec2-scale-down.ts +++ b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/scale-down.ts @@ -1,6 +1,6 @@ -import { bootTimeExceeded, listEC2Runners, tag, terminateRunner, untag } from './../aws/ec2-runners'; -import type { RunnerList } from './../aws/ec2-runners.d'; -import type { RunnerList as ScaleDownRunnerList, ScaleDownRunnerProvider } from './scale-down-provider'; +import type { ScaleDownRunnerList, ScaleDownRunnerProvider } from '../../../../core'; +import { bootTimeExceeded, listEC2Runners, tag, terminateRunner, untag } from './runners'; +import type { RunnerList } from './runners.d'; async function listEc2ScaleDownRunners(environment: string, orphan?: boolean): Promise { return (await listEC2Runners({ environment, orphan })).map(toScaleDownRunner); diff --git a/lambdas/functions/control-plane/src/scale-runners/ec2-scale-up.test.ts b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/scale-up.test.ts similarity index 99% rename from lambdas/functions/control-plane/src/scale-runners/ec2-scale-up.test.ts rename to lambdas/libs/runner-providers/aws/ec2/src/control-plane/scale-up.test.ts index a2ad607b0e..61287c89e5 100644 --- a/lambdas/functions/control-plane/src/scale-runners/ec2-scale-up.test.ts +++ b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/scale-up.test.ts @@ -6,17 +6,17 @@ import 'aws-sdk-client-mock-jest/vitest'; import nock from 'nock'; import { performance } from 'perf_hooks'; -import * as ghAuth from '../github/auth'; -import { createRunner, listEC2Runners, tag, terminateRunner } from './../aws/ec2-runners'; -import { RunnerInputParameters } from './../aws/ec2-runners.d'; -import * as scaleUpModule from './scale-up'; -import { parseEc2OverrideConfig } from './ec2-labels'; -import { EC2_TAG_VALUE_MAX_LENGTH, RUNNER_LABELS_TAG_MAX_COUNT } from './ec2'; +import * as ghAuth from '../../../../../../functions/control-plane/src/github/auth'; +import { publishRetryMessage } from '../../../../../../functions/control-plane/src/scale-runners/job-retry'; +import * as scaleUpModule from '../../../../../../functions/control-plane/src/scale-runners/scale-up'; +import type { ActionRequestMessageSQS } from '../../../../../../functions/control-plane/src/scale-runners/types'; +import { parseEc2OverrideConfig } from './dynamic-labels'; +import { EC2_TAG_VALUE_MAX_LENGTH, RUNNER_LABELS_TAG_MAX_COUNT } from './runner-config'; +import { createRunner, listEC2Runners, tag, terminateRunner } from './runners'; +import { RunnerInputParameters } from './runners.d'; import { getParameter } from '@aws-github-runner/aws-ssm-util'; -import { publishRetryMessage } from './job-retry'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import type { Octokit } from '@octokit/rest'; -import type { ActionRequestMessageSQS } from './types'; const mockOctokit = { paginate: vi.fn(), @@ -53,14 +53,14 @@ vi.mock('@octokit/rest', () => ({ }), })); -vi.mock('./../aws/ec2-runners', async () => ({ +vi.mock('./runners', async () => ({ createRunner: vi.fn(), listEC2Runners: vi.fn(), tag: vi.fn(), terminateRunner: vi.fn(), })); -vi.mock('./../github/auth', async () => ({ +vi.mock('../../../../../../functions/control-plane/src/github/auth', async () => ({ createGithubAppAuth: vi.fn(), createGithubInstallationAuth: vi.fn(), createOctokitClient: vi.fn(), @@ -77,7 +77,7 @@ vi.mock('@aws-github-runner/aws-ssm-util', async () => { }; }); -vi.mock('./job-retry', () => ({ +vi.mock('../../../../../../functions/control-plane/src/scale-runners/job-retry', () => ({ publishRetryMessage: vi.fn(), checkAndRetryJob: vi.fn(), })); diff --git a/lambdas/functions/control-plane/src/scale-runners/ec2-scale-up.ts b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/scale-up.ts similarity index 73% rename from lambdas/functions/control-plane/src/scale-runners/ec2-scale-up.ts rename to lambdas/libs/runner-providers/aws/ec2/src/control-plane/scale-up.ts index 52ce26d888..7f57df4838 100644 --- a/lambdas/functions/control-plane/src/scale-runners/ec2-scale-up.ts +++ b/lambdas/libs/runner-providers/aws/ec2/src/control-plane/scale-up.ts @@ -1,22 +1,23 @@ import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; -import yn from 'yn'; - -import { listEC2Runners } from '../aws/ec2-runners'; -import type { Ec2OverrideConfig } from './../aws/ec2-runners.d'; -import { - getDefaultBlockDeviceNameFromLaunchTemplate, - parseEc2OverrideConfig, - shouldLoadLaunchTemplateBlockDeviceName, -} from './ec2-labels'; -import { createRunners, loadEc2ProviderConfig } from './ec2'; -import type { CreateEC2RunnerConfig } from './ec2'; import type { CreateScaleUpRunnersInput, CreateScaleUpRunnersResult, + CreateStartRunnerConfig, CurrentRunnersInput, PreparedScaleUpRunnerGroup, ScaleUpRunnerProvider, -} from './scale-up-provider'; +} from '../../../../core'; +import yn from 'yn'; + +import { listEC2Runners } from './runners'; +import type { Ec2OverrideConfig } from './runners.d'; +import { + getDefaultBlockDeviceNameFromLaunchTemplate, + parseEc2OverrideConfig, + shouldLoadLaunchTemplateBlockDeviceName, +} from './dynamic-labels'; +import { createRunners, loadEc2ProviderConfig } from './runner-config'; +import type { CreateEC2RunnerConfig } from './runner-config'; const logger = createChildLogger('ec2-scale-up'); @@ -61,12 +62,10 @@ async function getCurrentEc2Runners( return (await listEC2Runners({ environment: process.env.ENVIRONMENT, runnerType, runnerOwner })).length; } -async function createEc2ScaleUpRunners({ - githubRunnerConfig, - numberOfRunners, - githubInstallationClient, - state, -}: CreateScaleUpRunnersInput): Promise { +async function createEc2ScaleUpRunners( + { githubRunnerConfig, numberOfRunners, githubInstallationClient, state }: CreateScaleUpRunnersInput, + createStartRunnerConfig: CreateStartRunnerConfig, +): Promise { const config = loadEc2ScaleUpProviderConfig(); return await createRunners( @@ -77,14 +76,17 @@ async function createEc2ScaleUpRunners({ }, numberOfRunners, githubInstallationClient, + createStartRunnerConfig, 'scale-up-lambda', ); } -export function createEc2ScaleUpProvider(): Omit, 'type'> { +export function createEc2ScaleUpProvider( + createStartRunnerConfig: CreateStartRunnerConfig, +): Omit, 'type'> { return { prepareGroup: prepareEc2ScaleUpGroup, getCurrentRunners: getCurrentEc2Runners, - createRunners: createEc2ScaleUpRunners, + createRunners: (input) => createEc2ScaleUpRunners(input, createStartRunnerConfig), }; } diff --git a/lambdas/libs/runner-providers/aws/ec2/src/environment.d.ts b/lambdas/libs/runner-providers/aws/ec2/src/environment.d.ts new file mode 100644 index 0000000000..71ee01ff2f --- /dev/null +++ b/lambdas/libs/runner-providers/aws/ec2/src/environment.d.ts @@ -0,0 +1,22 @@ +export {}; + +declare global { + namespace NodeJS { + interface ProcessEnv { + ENVIRONMENT: string; + LAUNCH_TEMPLATE_NAME: string; + SUBNET_IDS: string; + INSTANCE_TYPES: string; + INSTANCE_TARGET_CAPACITY_TYPE: 'on-demand' | 'spot'; + INSTANCE_MAX_SPOT_PRICE: string | undefined; + INSTANCE_ALLOCATION_STRATEGY: + | 'lowest-price' + | 'price-capacity-optimized' + | 'diversified' + | 'capacity-optimized' + | 'capacity-optimized-prioritized' + | 'prioritized'; + SCALE_ERRORS: string; + } + } +} diff --git a/lambdas/functions/webhook/src/runners/ec2-dynamic-labels-policy.test.ts b/lambdas/libs/runner-providers/aws/ec2/src/webhook/dynamic-labels-policy.test.ts similarity index 99% rename from lambdas/functions/webhook/src/runners/ec2-dynamic-labels-policy.test.ts rename to lambdas/libs/runner-providers/aws/ec2/src/webhook/dynamic-labels-policy.test.ts index 0497bee33a..d9d3c9325d 100644 --- a/lambdas/functions/webhook/src/runners/ec2-dynamic-labels-policy.test.ts +++ b/lambdas/libs/runner-providers/aws/ec2/src/webhook/dynamic-labels-policy.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { violationsAgainstPolicy, type Ec2DynamicLabelsPolicy } from './ec2-dynamic-labels-policy'; +import { violationsAgainstPolicy, type Ec2DynamicLabelsPolicy } from './dynamic-labels-policy'; describe('violationsAgainstPolicy', () => { it('returns [] when policy is null/undefined', () => { diff --git a/lambdas/functions/webhook/src/runners/ec2-dynamic-labels-policy.ts b/lambdas/libs/runner-providers/aws/ec2/src/webhook/dynamic-labels-policy.ts similarity index 98% rename from lambdas/functions/webhook/src/runners/ec2-dynamic-labels-policy.ts rename to lambdas/libs/runner-providers/aws/ec2/src/webhook/dynamic-labels-policy.ts index 15b6f4235d..a9b919c7bd 100644 --- a/lambdas/functions/webhook/src/runners/ec2-dynamic-labels-policy.ts +++ b/lambdas/libs/runner-providers/aws/ec2/src/webhook/dynamic-labels-policy.ts @@ -1,4 +1,4 @@ -import type { AwsDynamicLabelsPolicy, AwsDynamicLabelsValueRule } from './aws-dynamic-labels-policy'; +import type { AwsDynamicLabelsPolicy, AwsDynamicLabelsValueRule } from '../../../../contracts'; export type Ec2DynamicLabelsValueRule = AwsDynamicLabelsValueRule; diff --git a/lambdas/functions/webhook/src/runners/ec2-dynamic-labels.test.ts b/lambdas/libs/runner-providers/aws/ec2/src/webhook/dynamic-labels.test.ts similarity index 93% rename from lambdas/functions/webhook/src/runners/ec2-dynamic-labels.test.ts rename to lambdas/libs/runner-providers/aws/ec2/src/webhook/dynamic-labels.test.ts index f09ea6967b..d201f4f607 100644 --- a/lambdas/functions/webhook/src/runners/ec2-dynamic-labels.test.ts +++ b/lambdas/libs/runner-providers/aws/ec2/src/webhook/dynamic-labels.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import type { RunnerMatcherConfig } from '../sqs'; -import { selectEc2DynamicLabelQueue } from './ec2-dynamic-labels'; +import type { RunnerMatcherConfig } from '../../../../contracts'; +import { selectEc2DynamicLabelQueue } from './dynamic-labels'; describe('selectEc2DynamicLabelQueue', () => { it('enforces a legacy EC2 dynamic labels policy when the new key is absent', () => { diff --git a/lambdas/functions/webhook/src/runners/ec2-dynamic-labels.ts b/lambdas/libs/runner-providers/aws/ec2/src/webhook/dynamic-labels.ts similarity index 81% rename from lambdas/functions/webhook/src/runners/ec2-dynamic-labels.ts rename to lambdas/libs/runner-providers/aws/ec2/src/webhook/dynamic-labels.ts index 713a1f0348..6ddf5b8fbb 100644 --- a/lambdas/functions/webhook/src/runners/ec2-dynamic-labels.ts +++ b/lambdas/libs/runner-providers/aws/ec2/src/webhook/dynamic-labels.ts @@ -1,12 +1,11 @@ import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; -import { RunnerMatcherConfig } from '../sqs'; -import { AwsDynamicLabelDispatchTarget, AwsDynamicLabelProviderStrategy } from './aws-dynamic-labels-provider'; -import { violationsAgainstPolicy } from './ec2-dynamic-labels-policy'; +import type { DynamicLabelDispatchTarget, DynamicLabelProvider, RunnerMatcherConfig } from '../../../../contracts'; +import { violationsAgainstPolicy } from './dynamic-labels-policy'; const logger = createChildLogger('handler'); -export type Ec2DynamicLabelDispatchTarget = AwsDynamicLabelDispatchTarget; +export type Ec2DynamicLabelDispatchTarget = DynamicLabelDispatchTarget; function resolveEc2DynamicLabelsPolicy(queue: RunnerMatcherConfig) { const hasLegacyEc2DynamicLabelsPolicy = Object.prototype.hasOwnProperty.call( @@ -53,8 +52,7 @@ export function selectEc2DynamicLabelQueue( return undefined; } -export const ec2DynamicLabelProviderStrategy: AwsDynamicLabelProviderStrategy = { - type: 'ec2', +export const ec2DynamicLabelProvider: DynamicLabelProvider = { selectQueue: ({ queue, nonGhrLabels, sanitizedGhrLabels }) => selectEc2DynamicLabelQueue([queue], nonGhrLabels, sanitizedGhrLabels), }; diff --git a/lambdas/libs/runner-providers/aws/ec2/webhook.ts b/lambdas/libs/runner-providers/aws/ec2/webhook.ts new file mode 100644 index 0000000000..1ad62e00b4 --- /dev/null +++ b/lambdas/libs/runner-providers/aws/ec2/webhook.ts @@ -0,0 +1,16 @@ +import type { RunnerProviderPlugin } from '../../core'; + +import type { WebhookProviderCapabilities, WebhookProviderModule } from '../../contracts'; +import { ec2DynamicLabelProvider } from './src/webhook/dynamic-labels'; + +export function createEc2WebhookPlugin(): RunnerProviderPlugin { + return { + type: 'ec2', + capabilities: { dynamicLabels: ec2DynamicLabelProvider }, + }; +} + +export const provider = { + type: 'ec2', + createPlugin: createEc2WebhookPlugin, +} satisfies WebhookProviderModule<'ec2'>; diff --git a/lambdas/libs/runner-providers/contracts.ts b/lambdas/libs/runner-providers/contracts.ts new file mode 100644 index 0000000000..1d1fe01b85 --- /dev/null +++ b/lambdas/libs/runner-providers/contracts.ts @@ -0,0 +1,74 @@ +import type { + CreateStartRunnerConfig, + PoolRunnerProvider, + RunnerProviderPlugin, + ScaleDownRunnerProvider, + ScaleUpRunnerProvider, +} from './core'; +import type { RunnerProviderType } from './provider-types'; + +export interface AwsDynamicLabelsValueRule { + allowed?: string[]; + denied?: string[]; + max?: number | string; +} + +export interface AwsDynamicLabelsPolicy { + blocked_keys?: string[]; + restricted_keys?: Record; +} + +export interface MatcherConfig { + labelMatchers: string[][]; + exactMatch: boolean; + bidirectionalLabelMatch?: boolean; + enableDynamicLabels?: boolean; + awsDynamicLabelsPolicy?: AwsDynamicLabelsPolicy | null; + // TODO: Remove this legacy compatibility field and fallback in the next release. + /** @deprecated Use awsDynamicLabelsPolicy. Retained while existing SSM configurations migrate. */ + ec2DynamicLabelsPolicy?: AwsDynamicLabelsPolicy | null; +} + +export interface RunnerMatcherConfig { + id: string; + arn: string; + runnerProvider?: RunnerProviderType; + matcherConfig: MatcherConfig; +} + +export type RunnerConfig = RunnerMatcherConfig[]; + +export interface DynamicLabelDispatchTarget { + queue: RunnerMatcherConfig; + labels: string[]; +} + +export interface DynamicLabelProvider { + selectQueue(input: { + queue: RunnerMatcherConfig; + nonGhrLabels: string[]; + sanitizedGhrLabels: string[]; + }): DynamicLabelDispatchTarget | undefined; +} + +export interface ControlPlaneProviderCapabilities { + pool: () => Omit; + scaleUp: () => Omit; + scaleDown: () => Omit; +} + +export interface WebhookProviderCapabilities { + dynamicLabels: DynamicLabelProvider; +} + +export interface ControlPlaneProviderModule { + type: TType; + createPlugin( + createStartRunnerConfig: CreateStartRunnerConfig, + ): RunnerProviderPlugin; +} + +export interface WebhookProviderModule { + type: TType; + createPlugin(): RunnerProviderPlugin; +} diff --git a/lambdas/libs/runner-providers/control-plane.ts b/lambdas/libs/runner-providers/control-plane.ts new file mode 100644 index 0000000000..282949fe7e --- /dev/null +++ b/lambdas/libs/runner-providers/control-plane.ts @@ -0,0 +1,11 @@ +import type { CreateStartRunnerConfig } from './core'; +import { createRunnerProviderRegistry } from './core'; + +import type { ControlPlaneProviderCapabilities } from './contracts'; +import { enabledControlPlaneProviders } from './providers.config.control-plane'; + +export function createControlPlaneProviderRegistry(createStartRunnerConfig: CreateStartRunnerConfig) { + return createRunnerProviderRegistry( + enabledControlPlaneProviders.map((provider) => provider.createPlugin(createStartRunnerConfig)), + ); +} diff --git a/lambdas/libs/runner-providers/core/index.test.ts b/lambdas/libs/runner-providers/core/index.test.ts new file mode 100644 index 0000000000..b910a9f8ff --- /dev/null +++ b/lambdas/libs/runner-providers/core/index.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { createRunnerProviderRegistry } from './index'; + +describe('runner provider registry', () => { + const plugin = { + type: 'ec2' as const, + capabilities: { + scaleUp: () => 'scale-up', + pool: () => 'pool', + }, + }; + const registry = createRunnerProviderRegistry([plugin]); + + it('resolves capabilities dynamically', () => { + expect(registry.capability('ec2', 'scaleUp')()).toBe('scale-up'); + expect(registry.capability('ec2', 'pool')()).toBe('pool'); + }); +}); diff --git a/lambdas/libs/runner-providers/core/index.ts b/lambdas/libs/runner-providers/core/index.ts new file mode 100644 index 0000000000..e0e387acdf --- /dev/null +++ b/lambdas/libs/runner-providers/core/index.ts @@ -0,0 +1,146 @@ +import type { Octokit } from '@octokit/rest'; + +import type { RunnerProviderType } from '../provider-types'; + +export interface RunnerProvider { + type: RunnerProviderType; +} + +export type LambdaRunnerSource = 'scale-up-lambda' | 'pool-lambda'; +export type GitHubRunnerType = 'Org' | 'Repo'; + +export interface CreateGitHubRunnerConfig { + ephemeral: boolean; + ghesBaseUrl?: string; + enableJitConfig: boolean; + runnerLabels: string; + runnerGroup: string; + runnerNamePrefix: string; + runnerOwner: string; + runnerType: GitHubRunnerType; + disableAutoUpdate: boolean; + ssmTokenPath: string; + ssmConfigPath: string; + ssmParameterStoreTags: { Key: string; Value: string }[]; +} + +export interface GitHubRunnerMetadata { + githubRunnerId: string; + runnerLabels: string[]; +} + +export interface StartRunnerConfigOptions { + getSsmParameterTags?: (runnerId: string) => { Key: string; Value: string }[]; + onJitConfigCreated?: (runnerId: string, metadata: GitHubRunnerMetadata) => Promise; +} + +export type CreateStartRunnerConfig = ( + githubRunnerConfig: CreateGitHubRunnerConfig, + runnerIds: string[], + ghClient: Octokit, + options?: StartRunnerConfigOptions, +) => Promise; + +export interface CurrentRunnersInput { + runnerType: GitHubRunnerType; + runnerOwner: string; +} + +export interface CreateScaleUpRunnersInput { + githubRunnerConfig: CreateGitHubRunnerConfig; + numberOfRunners: number; + githubInstallationClient: Octokit; + state: TState; +} + +export interface PreparedScaleUpRunnerGroup { + runnerLabels: string[]; + state: TState; +} + +export interface CreateScaleUpRunnersResult { + instances: string[]; + retryableErrorCount: number; + nonRetryableErrorCount: number; +} + +export interface ScaleUpRunnerProvider extends RunnerProvider { + prepareGroup(messageLabels: string[]): Promise>; + getCurrentRunners(state: TState, input: CurrentRunnersInput): Promise; + createRunners(input: CreateScaleUpRunnersInput): Promise; +} + +export interface ScaleDownRunnerList { + id: string; + launchTime?: Date; + owner?: string; + type?: string; + repo?: string; + org?: string; + orphan?: boolean; + githubRunnerId?: string; + bypassRemoval?: boolean; +} + +export interface ScaleDownRunnerInfo extends ScaleDownRunnerList { + owner: string; + type: string; +} + +export interface ScaleDownRunnerProvider extends RunnerProvider { + list(environment: string, orphan?: boolean): Promise; + bootTimeExceeded(runner: ScaleDownRunnerInfo): boolean; + markOrphan(id: string): Promise; + unmarkOrphan(id: string): Promise; + terminate(id: string): Promise; +} + +export interface RunnerStatus { + busy: boolean; + status: string; +} + +export interface ListPoolRunnersInput { + environment: string; + runnerOwner: string; + runnerType: GitHubRunnerType; +} + +export interface CreatePoolRunnersInput { + githubRunnerConfig: CreateGitHubRunnerConfig; + numberOfRunners: number; + githubInstallationClient: Octokit; +} + +export interface PoolRunnerProvider extends RunnerProvider { + listRunners(input: ListPoolRunnersInput): Promise; + countAvailableRunners( + runners: TRunner[], + runnerStatus: Map, + includeBusyRunners: boolean, + ): number; + createRunners(input: CreatePoolRunnersInput): Promise; +} + +export interface RunnerProviderPlugin { + type: TType; + capabilities: TCapabilities; +} + +export function createRunnerProviderRegistry( + plugins: readonly RunnerProviderPlugin[], +) { + const pluginsByType = new Map(plugins.map((plugin) => [plugin.type, plugin])); + + function get(type: TType): RunnerProviderPlugin { + const plugin = pluginsByType.get(type); + if (!plugin) throw new Error(`No runner provider plugin registered for '${type}'`); + return plugin; + } + + return { + get, + capability: (type: TType, capability: TKey): TCapabilities[TKey] => + get(type).capabilities[capability], + }; +} diff --git a/lambdas/libs/runner-providers/package.json b/lambdas/libs/runner-providers/package.json new file mode 100644 index 0000000000..77c9ea71d2 --- /dev/null +++ b/lambdas/libs/runner-providers/package.json @@ -0,0 +1,47 @@ +{ + "name": "@aws-github-runner/runner-providers", + "version": "1.0.0", + "main": "contracts.ts", + "exports": { + ".": "./contracts.ts", + "./core": "./core/index.ts", + "./provider-types": "./provider-types.ts", + "./webhook": "./webhook.ts", + "./control-plane": "./control-plane.ts", + "./aws/ec2/webhook": "./aws/ec2/webhook.ts", + "./aws/ec2/control-plane": "./aws/ec2/control-plane.ts", + "./aws/ec2/control-plane/runners": "./aws/ec2/src/control-plane/runners.ts", + "./aws/ec2/control-plane/runner-config": "./aws/ec2/src/control-plane/runner-config.ts" + }, + "type": "module", + "license": "MIT", + "scripts": { + "test": "NODE_ENV=test nx test", + "test:watch": "NODE_ENV=test nx test --watch", + "lint": "eslint .", + "format": "prettier --write \"**/*.ts\"", + "format-check": "prettier --check \"**/*.ts\"", + "all": "yarn format && yarn lint && yarn test" + }, + "dependencies": { + "@aws-github-runner/aws-powertools-util": "*", + "@aws-github-runner/aws-ssm-util": "*", + "@aws-sdk/client-ec2": "^3.1009.0", + "@octokit/rest": "22.0.1", + "moment": "2.29.4", + "yn": "3.1.1" + }, + "devDependencies": { + "aws-sdk-client-mock": "^4.1.0", + "aws-sdk-client-mock-jest": "^4.1.0", + "nock": "^14.0.10" + }, + "nx": { + "includedScripts": [ + "format", + "format-check", + "lint", + "all" + ] + } +} diff --git a/lambdas/libs/runner-provider/src/index.test.ts b/lambdas/libs/runner-providers/provider-types.test.ts similarity index 75% rename from lambdas/libs/runner-provider/src/index.test.ts rename to lambdas/libs/runner-providers/provider-types.test.ts index 616ed40d37..d87a689716 100644 --- a/lambdas/libs/runner-provider/src/index.test.ts +++ b/lambdas/libs/runner-providers/provider-types.test.ts @@ -1,6 +1,17 @@ import { describe, expect, it } from 'vitest'; -import { normalizeRunnerProviderType, resolveRunnerProviderType } from './index'; +import { + defaultRunnerProvider, + normalizeRunnerProviderType, + resolveRunnerProviderType, + runnerProviderTypes, +} from './provider-types'; + +describe('runner provider configuration', () => { + it('defines an explicit default provider', () => { + expect(runnerProviderTypes).toContain(defaultRunnerProvider); + }); +}); describe('runner provider normalization', () => { it.each([ diff --git a/lambdas/libs/runner-provider/src/index.ts b/lambdas/libs/runner-providers/provider-types.ts similarity index 74% rename from lambdas/libs/runner-provider/src/index.ts rename to lambdas/libs/runner-providers/provider-types.ts index bb73cfd983..236a750544 100644 --- a/lambdas/libs/runner-provider/src/index.ts +++ b/lambdas/libs/runner-providers/provider-types.ts @@ -1,8 +1,8 @@ -// TODO: Add MicroVM when its webhook and control-plane provider implementations are available. -const runnerProviderTypes = ['ec2'] as const; +export const runnerProviderTypes = ['ec2'] as const; + export type RunnerProviderType = (typeof runnerProviderTypes)[number]; -const defaultRunnerProvider: RunnerProviderType = 'ec2'; +export const defaultRunnerProvider = 'ec2' satisfies RunnerProviderType; export function normalizeRunnerProviderType(type: unknown): RunnerProviderType | undefined { if (type === undefined) return defaultRunnerProvider; @@ -22,7 +22,3 @@ export function resolveRunnerProviderType(type: unknown): RunnerProviderType { return normalizedType; } - -export interface RunnerProvider { - type: RunnerProviderType; -} diff --git a/lambdas/libs/runner-providers/providers.config.control-plane.ts b/lambdas/libs/runner-providers/providers.config.control-plane.ts new file mode 100644 index 0000000000..55ebaca95e --- /dev/null +++ b/lambdas/libs/runner-providers/providers.config.control-plane.ts @@ -0,0 +1,5 @@ +import { provider as ec2 } from './aws/ec2/control-plane'; +import type { ControlPlaneProviderModule } from './contracts'; + +/** Provider plugins included in the control-plane bundle. */ +export const enabledControlPlaneProviders = [ec2] as const satisfies readonly ControlPlaneProviderModule[]; diff --git a/lambdas/libs/runner-providers/providers.config.webhook.ts b/lambdas/libs/runner-providers/providers.config.webhook.ts new file mode 100644 index 0000000000..19c92734da --- /dev/null +++ b/lambdas/libs/runner-providers/providers.config.webhook.ts @@ -0,0 +1,5 @@ +import { provider as ec2 } from './aws/ec2/webhook'; +import type { WebhookProviderModule } from './contracts'; + +/** Provider plugins included in the webhook bundle. */ +export const enabledWebhookProviders = [ec2] as const satisfies readonly WebhookProviderModule[]; diff --git a/lambdas/libs/runner-providers/registry.test.ts b/lambdas/libs/runner-providers/registry.test.ts new file mode 100644 index 0000000000..28a35a75f5 --- /dev/null +++ b/lambdas/libs/runner-providers/registry.test.ts @@ -0,0 +1,38 @@ +import { expect, it, vi } from 'vitest'; + +import { createControlPlaneProviderRegistry } from './control-plane'; +import { runnerProviderTypes } from './provider-types'; +import { enabledControlPlaneProviders } from './providers.config.control-plane'; +import { enabledWebhookProviders } from './providers.config.webhook'; +import { webhookProviderRegistry } from './webhook'; + +it('exposes every configured provider through both capability registries', () => { + const createStartRunnerConfig = vi.fn(async () => []); + const controlPlaneRegistry = createControlPlaneProviderRegistry(createStartRunnerConfig); + const controlPlaneTypes = enabledControlPlaneProviders.map(({ type }) => type); + const webhookTypes = enabledWebhookProviders.map(({ type }) => type); + + expect(controlPlaneTypes).toEqual(runnerProviderTypes); + expect(webhookTypes).toEqual(runnerProviderTypes); + + for (const type of runnerProviderTypes) { + expect(controlPlaneRegistry.capability(type, 'pool')()).toEqual({ + listRunners: expect.any(Function), + countAvailableRunners: expect.any(Function), + createRunners: expect.any(Function), + }); + expect(controlPlaneRegistry.capability(type, 'scaleUp')()).toEqual({ + prepareGroup: expect.any(Function), + getCurrentRunners: expect.any(Function), + createRunners: expect.any(Function), + }); + expect(controlPlaneRegistry.capability(type, 'scaleDown')()).toEqual({ + list: expect.any(Function), + bootTimeExceeded: expect.any(Function), + markOrphan: expect.any(Function), + unmarkOrphan: expect.any(Function), + terminate: expect.any(Function), + }); + expect(webhookProviderRegistry.capability(type, 'dynamicLabels').selectQueue).toEqual(expect.any(Function)); + } +}); diff --git a/lambdas/libs/runner-providers/templates/provider/README.md b/lambdas/libs/runner-providers/templates/provider/README.md new file mode 100644 index 0000000000..27fbc09086 --- /dev/null +++ b/lambdas/libs/runner-providers/templates/provider/README.md @@ -0,0 +1,29 @@ +# Runner provider template + +Copy this directory to the appropriate provider namespace, for example +`aws/codebuild`, and replace `template` with the new lane type. + +The template is compile-checked but intentionally not registered. A provider +has separate webhook and control-plane entry points so each Lambda bundles only +the code it uses. To enable a completed provider, add its lane type to +`provider-types.ts`, then register each entry point in its matching file: + +- `providers.config.webhook.ts` +- `providers.config.control-plane.ts` + +Each entry point exports its module as `provider`. Alias that export to the lane +name when enabling it, for example: + +```ts +import { provider as codebuild } from './aws/codebuild/webhook'; +``` + +Implement every capability before registering the provider: + +- `pool`: list managed runners, count available runners, and create runners. +- `scaleUp`: prepare lane state, count current runners, and create runners. +- `scaleDown`: list, inspect, mark, unmark, and terminate runners. +- `dynamicLabels`: select a webhook dispatch target for supported labels. + +Provider-specific tests should remain beside the provider implementation. The +generic orchestration contracts remain owned by the control-plane package. diff --git a/lambdas/libs/runner-providers/templates/provider/control-plane.ts b/lambdas/libs/runner-providers/templates/provider/control-plane.ts new file mode 100644 index 0000000000..61f6d5f482 --- /dev/null +++ b/lambdas/libs/runner-providers/templates/provider/control-plane.ts @@ -0,0 +1,90 @@ +import type { + CreateStartRunnerConfig, + PoolRunnerProvider, + RunnerProviderPlugin, + ScaleDownRunnerProvider, + ScaleUpRunnerProvider, +} from '../../core'; + +import type { ControlPlaneProviderCapabilities, ControlPlaneProviderModule } from '../../contracts'; + +export interface TemplateScaleUpState { + resourceGroupId: string; +} + +function notImplemented(operation: string): never { + throw new Error(`Template runner provider must implement ${operation}`); +} + +export function createTemplatePoolProvider( + createStartRunnerConfig: CreateStartRunnerConfig, +): Omit { + return { + listRunners: async () => notImplemented('pool.listRunners'), + countAvailableRunners: () => notImplemented('pool.countAvailableRunners'), + createRunners: async (input) => { + // Use this dependency after provisioning runner IDs to create their GitHub configuration. + void createStartRunnerConfig; + void input; + return notImplemented('pool.createRunners'); + }, + }; +} + +export function createTemplateScaleUpProvider( + createStartRunnerConfig: CreateStartRunnerConfig, +): Omit { + return { + prepareGroup: async (messageLabels) => { + void messageLabels; + return notImplemented('scaleUp.prepareGroup'); + }, + getCurrentRunners: async (state, input) => { + const templateState = state as TemplateScaleUpState; + void templateState; + void input; + return notImplemented('scaleUp.getCurrentRunners'); + }, + createRunners: async (input) => { + // Use this dependency after provisioning runner IDs to create their GitHub configuration. + void createStartRunnerConfig; + void input; + return notImplemented('scaleUp.createRunners'); + }, + }; +} + +export function createTemplateScaleDownProvider(): Omit { + return { + list: async (environment, orphan) => { + void environment; + void orphan; + return notImplemented('scaleDown.list'); + }, + bootTimeExceeded: (runner) => { + void runner; + return notImplemented('scaleDown.bootTimeExceeded'); + }, + markOrphan: async (id) => notImplemented(`scaleDown.markOrphan(${id})`), + unmarkOrphan: async (id) => notImplemented(`scaleDown.unmarkOrphan(${id})`), + terminate: async (id) => notImplemented(`scaleDown.terminate(${id})`), + }; +} + +export function createTemplateControlPlanePlugin( + createStartRunnerConfig: CreateStartRunnerConfig, +): RunnerProviderPlugin { + return { + type: 'template', + capabilities: { + pool: () => createTemplatePoolProvider(createStartRunnerConfig), + scaleUp: () => createTemplateScaleUpProvider(createStartRunnerConfig), + scaleDown: createTemplateScaleDownProvider, + }, + }; +} + +export const provider = { + type: 'template', + createPlugin: createTemplateControlPlanePlugin, +} satisfies ControlPlaneProviderModule<'template'>; diff --git a/lambdas/libs/runner-providers/templates/provider/provider.test.ts b/lambdas/libs/runner-providers/templates/provider/provider.test.ts new file mode 100644 index 0000000000..2d05fb3478 --- /dev/null +++ b/lambdas/libs/runner-providers/templates/provider/provider.test.ts @@ -0,0 +1,33 @@ +import { expect, it, vi } from 'vitest'; + +import { provider as controlPlaneProvider } from './control-plane'; +import { provider as webhookProvider } from './webhook'; + +it('exposes every runner provider capability from its lane entry point', () => { + const controlPlanePlugin = controlPlaneProvider.createPlugin(vi.fn(async () => [])); + const pool = controlPlanePlugin.capabilities.pool(); + const scaleUp = controlPlanePlugin.capabilities.scaleUp(); + const scaleDown = controlPlanePlugin.capabilities.scaleDown(); + const webhookPlugin = webhookProvider.createPlugin(); + + expect(controlPlanePlugin.type).toBe(controlPlaneProvider.type); + expect(pool).toEqual({ + listRunners: expect.any(Function), + countAvailableRunners: expect.any(Function), + createRunners: expect.any(Function), + }); + expect(scaleUp).toEqual({ + prepareGroup: expect.any(Function), + getCurrentRunners: expect.any(Function), + createRunners: expect.any(Function), + }); + expect(scaleDown).toEqual({ + list: expect.any(Function), + bootTimeExceeded: expect.any(Function), + markOrphan: expect.any(Function), + unmarkOrphan: expect.any(Function), + terminate: expect.any(Function), + }); + expect(webhookPlugin.type).toBe(webhookProvider.type); + expect(webhookPlugin.capabilities.dynamicLabels.selectQueue).toEqual(expect.any(Function)); +}); diff --git a/lambdas/libs/runner-providers/templates/provider/webhook.ts b/lambdas/libs/runner-providers/templates/provider/webhook.ts new file mode 100644 index 0000000000..a782ea8477 --- /dev/null +++ b/lambdas/libs/runner-providers/templates/provider/webhook.ts @@ -0,0 +1,23 @@ +import type { RunnerProviderPlugin } from '../../core'; + +import type { DynamicLabelProvider, WebhookProviderCapabilities, WebhookProviderModule } from '../../contracts'; + +export const templateDynamicLabelProvider: DynamicLabelProvider = { + selectQueue: (input) => { + void input; + // Return a dispatch target when this provider accepts the requested dynamic labels. + return undefined; + }, +}; + +export function createTemplateWebhookPlugin(): RunnerProviderPlugin { + return { + type: 'template', + capabilities: { dynamicLabels: templateDynamicLabelProvider }, + }; +} + +export const provider = { + type: 'template', + createPlugin: createTemplateWebhookPlugin, +} satisfies WebhookProviderModule<'template'>; diff --git a/lambdas/libs/runner-providers/tsconfig.json b/lambdas/libs/runner-providers/tsconfig.json new file mode 100644 index 0000000000..52d55867fe --- /dev/null +++ b/lambdas/libs/runner-providers/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["*.ts", "core/**/*", "aws/**/*", "templates/**/*"], + "exclude": ["aws/**/*.test.ts"] +} diff --git a/lambdas/libs/runner-providers/vitest.config.ts b/lambdas/libs/runner-providers/vitest.config.ts new file mode 100644 index 0000000000..fd62b358c0 --- /dev/null +++ b/lambdas/libs/runner-providers/vitest.config.ts @@ -0,0 +1,28 @@ +import { resolve } from 'path'; + +import { mergeConfig } from 'vitest/config'; +import defaultConfig from '../../vitest.base.config'; + +export default mergeConfig(defaultConfig, { + test: { + setupFiles: [resolve(__dirname, '../../aws-vitest-setup.ts')], + coverage: { + include: [ + 'contracts.ts', + 'provider-types.ts', + 'providers.config.*.ts', + 'control-plane.ts', + 'webhook.ts', + 'core/**/*.ts', + 'aws/**/*.ts', + ], + exclude: ['**/*.test.ts', '**/*.d.ts', 'templates/**/*'], + thresholds: { + statements: 96.16, + branches: 95.32, + functions: 93.06, + lines: 96.53, + }, + }, + }, +}); diff --git a/lambdas/libs/runner-providers/webhook.ts b/lambdas/libs/runner-providers/webhook.ts new file mode 100644 index 0000000000..e79e4624f9 --- /dev/null +++ b/lambdas/libs/runner-providers/webhook.ts @@ -0,0 +1,8 @@ +import { createRunnerProviderRegistry } from './core'; + +import type { WebhookProviderCapabilities } from './contracts'; +import { enabledWebhookProviders } from './providers.config.webhook'; + +export const webhookProviderRegistry = createRunnerProviderRegistry( + enabledWebhookProviders.map((provider) => provider.createPlugin()), +); diff --git a/lambdas/yarn.lock b/lambdas/yarn.lock index 46f50d677b..075f533467 100644 --- a/lambdas/yarn.lock +++ b/lambdas/yarn.lock @@ -147,7 +147,7 @@ __metadata: dependencies: "@aws-github-runner/aws-powertools-util": "npm:*" "@aws-github-runner/aws-ssm-util": "npm:*" - "@aws-github-runner/runner-provider": "npm:*" + "@aws-github-runner/runner-providers": "npm:*" "@aws-lambda-powertools/parameters": "npm:^2.31.0" "@aws-sdk/client-ec2": "npm:^3.1009.0" "@aws-sdk/client-sqs": "npm:^3.1009.0" @@ -193,9 +193,19 @@ __metadata: languageName: unknown linkType: soft -"@aws-github-runner/runner-provider@npm:*, @aws-github-runner/runner-provider@workspace:libs/runner-provider": +"@aws-github-runner/runner-providers@npm:*, @aws-github-runner/runner-providers@workspace:libs/runner-providers": version: 0.0.0-use.local - resolution: "@aws-github-runner/runner-provider@workspace:libs/runner-provider" + resolution: "@aws-github-runner/runner-providers@workspace:libs/runner-providers" + dependencies: + "@aws-github-runner/aws-powertools-util": "npm:*" + "@aws-github-runner/aws-ssm-util": "npm:*" + "@aws-sdk/client-ec2": "npm:^3.1009.0" + "@octokit/rest": "npm:22.0.1" + aws-sdk-client-mock: "npm:^4.1.0" + aws-sdk-client-mock-jest: "npm:^4.1.0" + moment: "npm:2.29.4" + nock: "npm:^14.0.10" + yn: "npm:3.1.1" languageName: unknown linkType: soft @@ -228,7 +238,7 @@ __metadata: dependencies: "@aws-github-runner/aws-powertools-util": "npm:*" "@aws-github-runner/aws-ssm-util": "npm:*" - "@aws-github-runner/runner-provider": "npm:*" + "@aws-github-runner/runner-providers": "npm:*" "@aws-sdk/client-eventbridge": "npm:^3.1009.0" "@aws-sdk/client-sqs": "npm:^3.1009.0" "@middy/core": "npm:^6.4.5" @@ -8885,7 +8895,7 @@ __metadata: languageName: node linkType: hard -"moment@npm:^2.29.4": +"moment@npm:2.29.4, moment@npm:^2.29.4": version: 2.29.4 resolution: "moment@npm:2.29.4" checksum: 10c0/844c6f3ce42862ac9467c8ca4f5e48a00750078682cc5bda1bc0e50cc7ca88e2115a0f932d65a06e4a90e26cb78892be9b3ca3dd6546ca2c4d994cebb787fc2b