From 741d9ff0dbae4bf48374a3e33e22fe9bfa0facc7 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 4 Sep 2026 12:34:22 +0200 Subject: [PATCH 1/6] refactor(storage): compose runner config storage providers --- .../control-plane/src/github/auth.ts | 41 ++-- .../src/pool/pool-contract.test.ts | 5 +- .../control-plane/src/pool/pool.test.ts | 4 +- .../functions/control-plane/src/pool/pool.ts | 39 ++-- .../src/scale-runners/github-runner.ts | 43 +--- .../src/scale-runners/scale-up.ts | 37 ++- .../aws/ec2/src/control-plane/pool.test.ts | 3 - .../aws/ec2/src/control-plane/pool.ts | 3 +- .../ec2/src/control-plane/runner-creation.ts | 11 +- .../ec2/src/control-plane/scale-up.test.ts | 2 - .../aws/ec2/src/control-plane/scale-up.ts | 3 +- lambdas/libs/compute-providers/core/index.ts | 5 +- .../aws/ssm/environment.d.ts | 1 + .../aws/ssm/parameter-store-tags.ts | 8 +- .../aws/ssm/runner-config-consumer-common.ts | 207 ++++++++++++++-- .../aws/ssm/runner-config-store.ts | 14 +- .../aws/ssm/runner-group-cache-store.ts | 16 +- lambdas/libs/storage-providers/core/index.ts | 12 + .../github-app-credentials.ts | 6 - lambdas/libs/storage-providers/index.ts | 7 +- .../libs/storage-providers/provider.test.ts | 15 ++ lambdas/libs/storage-providers/provider.ts | 16 ++ .../runner-config-consumer-common.ts | 220 ------------------ .../runner-config-consumer.ts | 2 +- .../storage-providers/runner-config.test.ts | 18 -- .../libs/storage-providers/runner-config.ts | 7 - .../storage-providers/runner-group-cache.ts | 6 - .../storage-providers.test.ts | 42 ++++ .../storage-providers/storage-providers.ts | 45 ++++ .../libs/storage-providers/vitest.config.ts | 6 +- 30 files changed, 466 insertions(+), 378 deletions(-) delete mode 100644 lambdas/libs/storage-providers/github-app-credentials.ts create mode 100644 lambdas/libs/storage-providers/provider.test.ts create mode 100644 lambdas/libs/storage-providers/provider.ts delete mode 100644 lambdas/libs/storage-providers/runner-config-consumer-common.ts delete mode 100644 lambdas/libs/storage-providers/runner-config.test.ts delete mode 100644 lambdas/libs/storage-providers/runner-config.ts delete mode 100644 lambdas/libs/storage-providers/runner-group-cache.ts create mode 100644 lambdas/libs/storage-providers/storage-providers.test.ts create mode 100644 lambdas/libs/storage-providers/storage-providers.ts diff --git a/lambdas/functions/control-plane/src/github/auth.ts b/lambdas/functions/control-plane/src/github/auth.ts index a70d9402cf..3e177ab253 100644 --- a/lambdas/functions/control-plane/src/github/auth.ts +++ b/lambdas/functions/control-plane/src/github/auth.ts @@ -7,7 +7,11 @@ import { Octokit } from '@octokit/rest'; import { retry } from '@octokit/plugin-retry'; import { throttling } from '@octokit/plugin-throttling'; import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; -import { createGitHubAppCredentialsStore, type GitHubAppCredential } from '@aws-github-runner/storage-providers'; +import { + createCommonStorage, + type GitHubAppCredential, + type GitHubAppCredentialsStore, +} from '@aws-github-runner/storage-providers'; import { EndpointDefaults } from '@octokit/types'; type AppAuthOptions = { type: 'app' }; @@ -56,31 +60,37 @@ export function onSecondaryRateLimit( let appCredentialsPromise: Promise | null = null; async function loadAppCredentials(): Promise { - const credentials = await createGitHubAppCredentialsStore().get(); + const credentials = await createCommonStorage().githubAppCredentials.get(); logger.info(`Loaded ${credentials.length} GitHub App credential(s)`); return credentials; } -function getAppCredentials(): Promise { +function getAppCredentials(credentialsStore?: GitHubAppCredentialsStore): Promise { + if (credentialsStore) { + return credentialsStore.get(); + } if (!appCredentialsPromise) appCredentialsPromise = loadAppCredentials(); return appCredentialsPromise; } -export async function getAppCount(): Promise { - return (await getAppCredentials()).length; +export async function getAppCount(credentialsStore?: GitHubAppCredentialsStore): Promise { + return (await getAppCredentials(credentialsStore)).length; } export function resetAppCredentialsCache(): void { appCredentialsPromise = null; } -export async function getStoredInstallationId(appIndex: number): Promise { - const credentials = await getAppCredentials(); +export async function getStoredInstallationId( + appIndex: number, + credentialsStore?: GitHubAppCredentialsStore, +): Promise { + const credentials = await getAppCredentials(credentialsStore); return credentials[appIndex]?.installationId; } -export async function getAppId(appIndex = 0): Promise { - const credential = (await getAppCredentials())[appIndex]; +export async function getAppId(appIndex = 0, credentialsStore?: GitHubAppCredentialsStore): Promise { + const credential = (await getAppCredentials(credentialsStore))[appIndex]; if (!credential) { throw new Error(`GitHub App credential at index ${appIndex} not found`); } @@ -117,10 +127,11 @@ export async function createGithubAppAuth( installationId: number | undefined, ghesApiUrl = '', appIndex?: number, + credentialsStore?: GitHubAppCredentialsStore, ): Promise { - const credentials = await getAppCredentials(); + const credentials = await getAppCredentials(credentialsStore); const idx = appIndex ?? Math.floor(Math.random() * credentials.length); - const auth = await createAuth(installationId, ghesApiUrl, idx); + const auth = await createAuth(installationId, ghesApiUrl, idx, credentialsStore); return { ...(await auth({ type: 'app' })), appIndex: idx }; } @@ -128,10 +139,11 @@ export async function createGithubInstallationAuth( installationId: number | undefined, ghesApiUrl = '', appIndex?: number, + credentialsStore?: GitHubAppCredentialsStore, ): Promise { - const credentials = await getAppCredentials(); + const credentials = await getAppCredentials(credentialsStore); const idx = appIndex ?? Math.floor(Math.random() * credentials.length); - const auth = await createAuth(installationId, ghesApiUrl, idx); + const auth = await createAuth(installationId, ghesApiUrl, idx, credentialsStore); return auth({ type: 'installation', installationId }); } @@ -147,8 +159,9 @@ async function createAuth( installationId: number | undefined, ghesApiUrl: string, appIndex?: number, + credentialsStore?: GitHubAppCredentialsStore, ): Promise { - const credentials = await getAppCredentials(); + const credentials = await getAppCredentials(credentialsStore); const selected = appIndex !== undefined ? credentials[appIndex] : credentials[Math.floor(Math.random() * credentials.length)]; if (!selected) { diff --git a/lambdas/functions/control-plane/src/pool/pool-contract.test.ts b/lambdas/functions/control-plane/src/pool/pool-contract.test.ts index 1a1e88a7f6..1a208edde7 100644 --- a/lambdas/functions/control-plane/src/pool/pool-contract.test.ts +++ b/lambdas/functions/control-plane/src/pool/pool-contract.test.ts @@ -20,7 +20,6 @@ vi.mock('../github/auth', () => ({ vi.mock('../scale-runners/github-runner', () => ({ createStartRunnerConfig: vi.fn(), getGitHubEnterpriseApiUrl: vi.fn(), - validateSsmParameterStoreTags: vi.fn(), })); const mockedAppAuth = vi.mocked(ghAuth.createGithubAppAuth); @@ -49,6 +48,9 @@ beforeEach(() => { vi.clearAllMocks(); process.env = { ...cleanEnv }; process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/tokens'; + process.env.SSM_CONFIG_PATH = '/github-action-runners/default/runners/config'; + process.env.PARAMETER_GITHUB_APP_ID_NAME = 'github-app-id'; + process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = 'github-app-key'; mockedAppAuth.mockResolvedValue({ type: 'app', token: 'app-token', appId: 1, expiresAt: 'some-date' }); mockedInstallationAuth.mockResolvedValue({ @@ -63,7 +65,6 @@ beforeEach(() => { }); 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([]); }); diff --git a/lambdas/functions/control-plane/src/pool/pool.test.ts b/lambdas/functions/control-plane/src/pool/pool.test.ts index 8372ab6403..ccaad541a0 100644 --- a/lambdas/functions/control-plane/src/pool/pool.test.ts +++ b/lambdas/functions/control-plane/src/pool/pool.test.ts @@ -31,7 +31,6 @@ vi.mock('../scale-runners/github-runner', () => ({ ghesApiUrl: '', ghesBaseUrl: '', }), - validateSsmParameterStoreTags: vi.fn().mockReturnValue([]), })); const mockedAppAuth = vi.mocked(ghAuth.createGithubAppAuth); @@ -93,6 +92,9 @@ beforeEach(() => { process.env.RUNNERS_MAXIMUM_COUNT = '-1'; process.env.ENVIRONMENT = 'unit-test-environment'; process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/tokens'; + process.env.SSM_CONFIG_PATH = '/github-action-runners/default/runners/config'; + process.env.PARAMETER_GITHUB_APP_ID_NAME = 'github-app-id'; + process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = 'github-app-key'; process.env.RUNNER_OWNER = ORG; githubClient.paginate.mockResolvedValue(githubRunnersRegistered); diff --git a/lambdas/functions/control-plane/src/pool/pool.ts b/lambdas/functions/control-plane/src/pool/pool.ts index 029c494bac..5c7f28a6fc 100644 --- a/lambdas/functions/control-plane/src/pool/pool.ts +++ b/lambdas/functions/control-plane/src/pool/pool.ts @@ -1,6 +1,7 @@ import { Octokit } from '@octokit/rest'; import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; import { resolveComputeProviderType } from '@aws-github-runner/compute-providers/provider-types'; +import { createStorageProviders, type StorageProviders } from '@aws-github-runner/storage-providers'; import yn from 'yn'; import { @@ -10,7 +11,7 @@ import { getStoredInstallationId, } from '../github/auth'; import { controlPlaneProviderRegistry } from '../control-plane-providers'; -import { getGitHubEnterpriseApiUrl, validateSsmParameterStoreTags } from '../scale-runners/github-runner'; +import { getGitHubEnterpriseApiUrl } from '../scale-runners/github-runner'; import type { RunnerStatus } from './pool-provider'; const logger = createChildLogger('pool'); @@ -21,6 +22,7 @@ export interface PoolEvent { } export async function adjust(event: PoolEvent): Promise { + const storage = createStorageProviders(); const computeProviderType = resolveComputeProviderType(event.type); const computeProvider = { ...controlPlaneProviderRegistry.capability(computeProviderType, 'pool')(), @@ -31,15 +33,10 @@ export async function adjust(event: PoolEvent): Promise { const runnerGroup = process.env.RUNNER_GROUP_NAME || ''; const runnerNamePrefix = process.env.RUNNER_NAME_PREFIX || ''; const environment = process.env.ENVIRONMENT; - const ssmConfigPath = process.env.SSM_CONFIG_PATH || ''; const ephemeral = yn(process.env.ENABLE_EPHEMERAL_RUNNERS, { default: false }); const enableJitConfig = yn(process.env.ENABLE_JIT_CONFIG, { default: ephemeral }); const disableAutoUpdate = yn(process.env.DISABLE_RUNNER_AUTOUPDATE, { default: false }); const runnerOwner = process.env.RUNNER_OWNER; - const ssmParameterStoreTags: { Key: string; Value: string }[] = - process.env.SSM_PARAMETER_STORE_TAGS && process.env.SSM_PARAMETER_STORE_TAGS.trim() !== '' - ? validateSsmParameterStoreTags(process.env.SSM_PARAMETER_STORE_TAGS) - : []; // -1 disables the maximum check, matching the scale-up lambda's semantics. Defaults to unlimited // when unset so the pool keeps its previous behavior on stacks that do not provide the variable. const maximumRunners = parseInt(process.env.RUNNERS_MAXIMUM_COUNT || '-1'); @@ -49,11 +46,22 @@ export async function adjust(event: PoolEvent): Promise { // Select one GitHub App for this entire invocation so every API call draws // from the same rate-limit bucket. - const ghAppAuth = await createGithubAppAuth(undefined, ghesApiUrl); + const ghAppAuth = await createGithubAppAuth(undefined, ghesApiUrl, undefined, storage.githubAppCredentials); const appIdx = ghAppAuth.appIndex; - const installationId = await getInstallationId(ghAppAuth.token, ghesApiUrl, runnerOwner, appIdx); - const ghAuth = await createGithubInstallationAuth(installationId, ghesApiUrl, appIdx); + const installationId = await getInstallationId( + ghAppAuth.token, + ghesApiUrl, + runnerOwner, + appIdx, + storage, + ); + const ghAuth = await createGithubInstallationAuth( + installationId, + ghesApiUrl, + appIdx, + storage.githubAppCredentials, + ); const githubInstallationClient = await createOctokitClient(ghAuth.token, ghesApiUrl); // Get statuses of runners registered in GitHub @@ -102,20 +110,25 @@ export async function adjust(event: PoolEvent): Promise { runnerNamePrefix, runnerType: 'Org', disableAutoUpdate: disableAutoUpdate, - ssmConfigPath, - ssmParameterStoreTags, }, numberOfRunners: topUp, githubInstallationClient, + storage, }); } else { logger.info(`Pool will not be topped up. Found ${numberOfRunnersInPool} managed idle runners.`); } } -async function getInstallationId(appToken: string, ghesApiUrl: string, org: string, appIndex: number): Promise { +async function getInstallationId( + appToken: string, + ghesApiUrl: string, + org: string, + appIndex: number, + storage?: StorageProviders, +): Promise { // Use the pre-configured installation ID when available (avoids an API call). - const storedId = await getStoredInstallationId(appIndex); + const storedId = await getStoredInstallationId(appIndex, storage?.githubAppCredentials); if (storedId !== undefined) return storedId; const githubClient = await createOctokitClient(appToken, ghesApiUrl); diff --git a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts index 31534adb79..b344012149 100644 --- a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts +++ b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts @@ -1,9 +1,9 @@ import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; import { - createRunnerConfigStore, - createRunnerGroupCacheStore, + createStorageProviders, type RunnerConfigMetadata, type RunnerConfigStore, + type GitHubAppCredentialsStore, type RunnerGroupCacheStore, } from '@aws-github-runner/storage-providers'; import { Octokit } from '@octokit/rest'; @@ -20,6 +20,7 @@ export interface GitHubRunnerMetadata { } export interface StartRunnerConfigOptions { + runnerConfigStore?: RunnerConfigStore; runnerGroupCacheStore?: RunnerGroupCacheStore; getRunnerConfigMetadata?: (runnerId: string) => RunnerConfigMetadata[]; onJitConfigCreated?: (runnerId: string, metadata: GitHubRunnerMetadata) => Promise; @@ -58,37 +59,6 @@ function quoteShellArg(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } -export function validateSsmParameterStoreTags(tagsJson: string): { Key: string; Value: string }[] { - try { - const tags = JSON.parse(tagsJson); - - if (!Array.isArray(tags)) { - throw new Error('Tags must be an array'); - } - - if (tags.length === 0) { - return []; - } - - tags.forEach((tag, index) => { - if (typeof tag !== 'object' || tag === null) { - throw new Error(`Tag at index ${index} must be an object`); - } - if (!tag.Key || typeof tag.Key !== 'string' || tag.Key.trim() === '') { - throw new Error(`Tag at index ${index} has missing or invalid 'Key' property`); - } - if (!Object.prototype.hasOwnProperty.call(tag, 'Value') || typeof tag.Value !== 'string') { - throw new Error(`Tag at index ${index} has missing or invalid 'Value' property`); - } - }); - - return tags; - } catch (err) { - logger.error('Invalid SSM_PARAMETER_STORE_TAGS format', { error: err }); - throw new Error(`Failed to parse SSM_PARAMETER_STORE_TAGS: ${(err as Error).message}`); - } -} - async function getGithubRunnerRegistrationToken(githubRunnerConfig: CreateGitHubRunnerConfig, ghClient: Octokit) { const registrationToken = githubRunnerConfig.runnerType === 'Org' @@ -137,10 +107,11 @@ export async function getInstallationId( enableOrgLevel: boolean, payload: ActionRequestMessage, appIndex?: number, + credentialsStore?: GitHubAppCredentialsStore, ): Promise { // Use the pre-configured installation ID when available (avoids an API call). if (appIndex !== undefined) { - const storedId = await getStoredInstallationId(appIndex); + const storedId = await getStoredInstallationId(appIndex, credentialsStore); if (storedId !== undefined) return storedId; } @@ -194,7 +165,7 @@ export async function getRunnerGroupId( // if the runnerType is Repo, then runnerGroupId is default to 1 let runnerGroupId: number | undefined = 1; if (githubRunnerConfig.runnerType === 'Org' && githubRunnerConfig.runnerGroup !== undefined) { - const cacheStore = runnerGroupCacheStore ?? createRunnerGroupCacheStore(); + const cacheStore = runnerGroupCacheStore ?? createStorageProviders().runnerGroupCache; const runnerGroup = await cacheStore.get(githubRunnerConfig.runnerGroup); if (runnerGroup === undefined) { // get runner group id from GitHub @@ -236,7 +207,7 @@ export async function createStartRunnerConfig( ghClient: Octokit, options: StartRunnerConfigOptions = {}, ): Promise { - const runnerConfigStore = createRunnerConfigStore(); + const runnerConfigStore = options.runnerConfigStore ?? createStorageProviders().runnerConfig; if (githubRunnerConfig.enableJitConfig && githubRunnerConfig.ephemeral) { return await createJitConfig(githubRunnerConfig, runnerIds, ghClient, runnerConfigStore, options); } else { 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 0b00d620b3..6633f5c6b3 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.ts @@ -1,5 +1,6 @@ import { addPersistentContextToChildLogger, createChildLogger } from '@aws-github-runner/aws-powertools-util'; import { resolveComputeProviderType } from '@aws-github-runner/compute-providers/provider-types'; +import { createStorageProviders, type StorageProviders } from '@aws-github-runner/storage-providers'; import { Octokit } from '@octokit/rest'; import yn from 'yn'; @@ -11,7 +12,6 @@ import { resolveInstallationId, isJobQueued, UnsupportedEventError, - validateSsmParameterStoreTags, } from './github-runner'; import { publishRetryMessage } from './job-retry'; import type { @@ -39,11 +39,23 @@ async function createGithubInstallationClient( payload: ActionRequestMessage, ghesApiUrl: string, appIndex?: number, + storage?: StorageProviders, ): Promise { - const installationId = await getInstallationId(githubAppClient, enableOrgLevel, payload, appIndex); + const installationId = await getInstallationId( + githubAppClient, + enableOrgLevel, + payload, + appIndex, + storage?.githubAppCredentials, + ); try { - const ghAuth = await createGithubInstallationAuth(installationId, ghesApiUrl, appIndex); + const ghAuth = await createGithubInstallationAuth( + installationId, + ghesApiUrl, + appIndex, + storage?.githubAppCredentials, + ); return await createOctokitClient(ghAuth.token, ghesApiUrl); } catch (error) { // The installation id can be stale when it was reused from the webhook payload or from the @@ -66,12 +78,18 @@ async function createGithubInstallationClient( repositoryName: payload.repositoryName, }); - const ghAuth = await createGithubInstallationAuth(resolvedInstallationId, ghesApiUrl, appIndex); + const ghAuth = await createGithubInstallationAuth( + resolvedInstallationId, + ghesApiUrl, + appIndex, + storage?.githubAppCredentials, + ); return await createOctokitClient(ghAuth.token, ghesApiUrl); } } export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise { + const storage = createStorageProviders(); logger.info('Received scale up requests', { n_requests: payloads.length, }); @@ -85,11 +103,6 @@ export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise { runnerOwner: 'owner', runnerType: 'Org', disableAutoUpdate: false, - ssmTokenPath: '/runners/tokens', - ssmConfigPath: '/runners/config', - ssmParameterStoreTags: [], }; const providerConfig: Ec2ProviderConfig = { environment: 'test-environment', diff --git a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/pool.ts b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/pool.ts index f032a78e20..43ec0aacf9 100644 --- a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/pool.ts +++ b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/pool.ts @@ -44,7 +44,7 @@ export function createEc2PoolCapability( statuses: ['running'], }), countAvailableRunners: countAvailableEc2PoolRunners, - createRunners: async ({ githubRunnerConfig, numberOfRunners, githubInstallationClient }) => { + createRunners: async ({ githubRunnerConfig, numberOfRunners, githubInstallationClient, storage }) => { const config = loadEc2ProviderConfig(); const { instances } = await createRunners( @@ -64,6 +64,7 @@ export function createEc2PoolCapability( githubInstallationClient, createStartRunnerConfig, 'pool-lambda', + storage, ); return instances; }, diff --git a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/runner-creation.ts b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/runner-creation.ts index 59d01733eb..124b676ae6 100644 --- a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/runner-creation.ts +++ b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/runner-creation.ts @@ -7,6 +7,7 @@ import type { RunnerSource, StartRunnerConfigOptions, } from '../../../../core'; +import type { RunnerConfigStorage } from '@aws-github-runner/storage-providers'; import { Octokit } from '@octokit/rest'; import type { Tag } from '@aws-sdk/client-ec2'; import yn from 'yn'; @@ -69,6 +70,7 @@ export async function createRunners( ghClient: Octokit, createStartRunnerConfig: CreateStartRunnerConfig, source: RunnerSource, + storage?: RunnerConfigStorage, ): Promise { let result: CreateRunnerResult; try { @@ -97,7 +99,7 @@ export async function createRunners( githubRunnerConfig, result.instances, ghClient, - createEc2StartRunnerConfigOptions(ec2Operations), + createEc2StartRunnerConfigOptions(ec2Operations, storage), ); } catch (error) { logger.error('Unexpected error while registering GitHub runners.', { @@ -146,8 +148,13 @@ async function terminateFailedInstances( } } -function createEc2StartRunnerConfigOptions(ec2Operations: Ec2RunnerResourceOperations): StartRunnerConfigOptions { +function createEc2StartRunnerConfigOptions( + ec2Operations: Ec2RunnerResourceOperations, + storage?: RunnerConfigStorage, +): StartRunnerConfigOptions { return { + runnerConfigStore: storage?.runnerConfig, + runnerGroupCacheStore: storage?.runnerGroupCache, getRunnerConfigMetadata: (instanceId) => [{ key: 'InstanceId', value: instanceId }], onJitConfigCreated: async (instanceId, metadata) => await tagEc2RunnerMetadata(ec2Operations, instanceId, metadata), }; diff --git a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.test.ts b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.test.ts index 98c376e028..1512955e05 100644 --- a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.test.ts +++ b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.test.ts @@ -51,8 +51,6 @@ function runnerConfig(overrides: Partial = {}): Create runnerOwner, runnerType: 'Org', disableAutoUpdate: false, - ssmConfigPath: '/github-action-runners/default/runners/config', - ssmParameterStoreTags: [], ...overrides, }; } diff --git a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.ts b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.ts index 0cbda11a8f..951193a4fb 100644 --- a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.ts +++ b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/scale-up.ts @@ -55,7 +55,7 @@ export function createEc2ScaleUpCapability( resolveLabelsForRunners: (labels) => resolveEc2ScaleUpRunnerLabels(ec2Operations, labels), getCurrentRunners: async (_state, { runnerType, runnerOwner }) => (await ec2Operations.list({ environment: process.env.ENVIRONMENT, runnerType, runnerOwner })).length, - createRunners: async ({ githubRunnerConfig, numberOfRunners, githubInstallationClient, state }) => { + createRunners: async ({ githubRunnerConfig, numberOfRunners, githubInstallationClient, state, storage }) => { const config = loadEc2ScaleUpProviderConfig(); return await createRunners( @@ -69,6 +69,7 @@ export function createEc2ScaleUpCapability( githubInstallationClient, createStartRunnerConfig, 'scale-up-lambda', + storage, ); }, }; diff --git a/lambdas/libs/compute-providers/core/index.ts b/lambdas/libs/compute-providers/core/index.ts index 772455c84c..8579932f00 100644 --- a/lambdas/libs/compute-providers/core/index.ts +++ b/lambdas/libs/compute-providers/core/index.ts @@ -21,8 +21,6 @@ export interface CreateGitHubRunnerConfig { runnerOwner: string; runnerType: RunnerType; disableAutoUpdate: boolean; - ssmConfigPath: string; - ssmParameterStoreTags: { Key: string; Value: string }[]; } export interface GitHubRunnerMetadata { @@ -31,6 +29,7 @@ export interface GitHubRunnerMetadata { } export interface StartRunnerConfigOptions { + runnerConfigStore?: import('@aws-github-runner/storage-providers').RunnerConfigStore; runnerGroupCacheStore?: import('@aws-github-runner/storage-providers').RunnerGroupCacheStore; getRunnerConfigMetadata?: (runnerId: string) => { key: string; value: string }[]; onJitConfigCreated?: (runnerId: string, metadata: GitHubRunnerMetadata) => Promise; @@ -53,6 +52,7 @@ export interface CreateScaleUpRunnersInput { numberOfRunners: number; githubInstallationClient: Octokit; state: TState; + storage?: import('@aws-github-runner/storage-providers').RunnerConfigStorage; } export interface RunnerLabelResolution { @@ -114,6 +114,7 @@ export interface CreatePoolRunnersInput { githubRunnerConfig: CreateGitHubRunnerConfig; numberOfRunners: number; githubInstallationClient: Octokit; + storage?: import('@aws-github-runner/storage-providers').RunnerConfigStorage; } export interface PoolComputeProvider extends ComputeProvider { diff --git a/lambdas/libs/storage-providers/aws/ssm/environment.d.ts b/lambdas/libs/storage-providers/aws/ssm/environment.d.ts index dc236ceedc..fe29bfc244 100644 --- a/lambdas/libs/storage-providers/aws/ssm/environment.d.ts +++ b/lambdas/libs/storage-providers/aws/ssm/environment.d.ts @@ -4,6 +4,7 @@ declare global { namespace NodeJS { interface ProcessEnv { SSM_PARAMETER_STORE_TAGS?: string; + SSM_CONFIG_PATH?: string; SSM_TOKEN_PATH?: string; PARAMETER_GITHUB_APP_ID_NAME?: string; PARAMETER_GITHUB_APP_KEY_BASE64_NAME?: string; diff --git a/lambdas/libs/storage-providers/aws/ssm/parameter-store-tags.ts b/lambdas/libs/storage-providers/aws/ssm/parameter-store-tags.ts index d35150e10a..07c329c4dc 100644 --- a/lambdas/libs/storage-providers/aws/ssm/parameter-store-tags.ts +++ b/lambdas/libs/storage-providers/aws/ssm/parameter-store-tags.ts @@ -3,9 +3,11 @@ interface SsmParameterStoreTag { Value: string; } -export function loadSsmParameterStoreTagsFromEnvironment(): SsmParameterStoreTag[] { - return process.env.SSM_PARAMETER_STORE_TAGS && process.env.SSM_PARAMETER_STORE_TAGS.trim() !== '' - ? validateSsmParameterStoreTags(process.env.SSM_PARAMETER_STORE_TAGS) +export function loadSsmParameterStoreTagsFromEnvironment( + environment: Readonly> = process.env, +): SsmParameterStoreTag[] { + return environment.SSM_PARAMETER_STORE_TAGS && environment.SSM_PARAMETER_STORE_TAGS.trim() !== '' + ? validateSsmParameterStoreTags(environment.SSM_PARAMETER_STORE_TAGS) : []; } diff --git a/lambdas/libs/storage-providers/aws/ssm/runner-config-consumer-common.ts b/lambdas/libs/storage-providers/aws/ssm/runner-config-consumer-common.ts index 91dbfc696f..9b69ad3a5d 100644 --- a/lambdas/libs/storage-providers/aws/ssm/runner-config-consumer-common.ts +++ b/lambdas/libs/storage-providers/aws/ssm/runner-config-consumer-common.ts @@ -1,14 +1,193 @@ -export { - canonicalSsmTokenPath, - composeSsmParameterName, - delay, - errorName, - isRetryableProviderError, - positiveIntegerOption, - resolvePollingOptions, - throwIfCancelled, - validateConsumeOptions, - withCallDeadline, - type ResolvedRunnerConfigPollingOptions, - type RunnerConfigPollingOptions, -} from '../../runner-config-consumer-common'; +import type { RunnerConfigConsumeOptions } from '../../core'; + +export const DEFAULT_CALL_TIMEOUT_MS = 5_000; +export const DEFAULT_CONFIG_TIMEOUT_MS = 40_000; +export const DEFAULT_POLL_INTERVAL_MS = 2_000; + +const RUNNER_ID_PATTERN = /^[A-Za-z0-9_.-]{1,256}$/; +const SSM_PARAMETER_PATH_PATTERN = /^\/[A-Za-z0-9_.\-/]+$/; +const MAX_SSM_PARAMETER_NAME_LENGTH = 900; +const RETRYABLE_ERROR_NAMES = new Set([ + 'AbortError', + 'ConnectionError', + 'InternalServerException', + 'ProvisionedThroughputExceededException', + 'RequestLimitExceeded', + 'RequestTimeout', + 'ServiceUnavailable', + 'ThrottlingException', + 'TimeoutError', +]); + +export interface RunnerConfigPollingOptions { + callTimeoutMs?: number; + configTimeoutMs?: number; + pollIntervalMs?: number; +} + +export interface ResolvedRunnerConfigPollingOptions { + callTimeoutMs: number; + configTimeoutMs: number; + pollIntervalMs: number; +} + +class RunnerConfigCallDeadlineError extends Error { + public constructor() { + super('runner configuration provider call exceeded its deadline'); + this.name = 'RunnerConfigCallDeadlineError'; + } +} + +export function resolvePollingOptions(options: RunnerConfigPollingOptions): ResolvedRunnerConfigPollingOptions { + return { + callTimeoutMs: positiveIntegerOption('callTimeoutMs', options.callTimeoutMs, DEFAULT_CALL_TIMEOUT_MS), + configTimeoutMs: positiveIntegerOption('configTimeoutMs', options.configTimeoutMs, DEFAULT_CONFIG_TIMEOUT_MS), + pollIntervalMs: positiveIntegerOption('pollIntervalMs', options.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS), + }; +} + +export function positiveIntegerOption(name: string, value: number | undefined, fallback: number): number { + if (value === undefined) return fallback; + if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`); + return value; +} + +export function validateRunnerId(runnerId: string): void { + if (!RUNNER_ID_PATTERN.test(runnerId)) throw new Error('runnerId is invalid'); +} + +export function canonicalSsmTokenPath(tokenPath: string): string { + if (tokenPath.includes('//')) throw new Error('aws_ssm tokenPath is invalid'); + const canonical = tokenPath.endsWith('/') ? tokenPath.slice(0, -1) : tokenPath; + const segments = canonical.split('/').slice(1); + if ( + canonical.length === 0 || + canonical.length > MAX_SSM_PARAMETER_NAME_LENGTH || + !SSM_PARAMETER_PATH_PATTERN.test(canonical) || + segments.length > 14 || + segments.some((segment) => segment === '' || segment === '.' || segment === '..') || + /^(aws|ssm)/i.test(segments[0] ?? '') + ) { + throw new Error('aws_ssm tokenPath is invalid'); + } + return canonical; +} + +export function composeSsmParameterName(tokenPath: string, runnerId: string): string { + validateRunnerId(runnerId); + const parameterName = `${canonicalSsmTokenPath(tokenPath)}/${runnerId}`; + const segments = parameterName.split('/').slice(1); + if (parameterName.length > MAX_SSM_PARAMETER_NAME_LENGTH || segments.length > 15) { + throw new Error('aws_ssm runner configuration key is invalid'); + } + return parameterName; +} + +export function validateConsumeOptions(options: RunnerConfigConsumeOptions): void { + if (!Number.isSafeInteger(options.deadlineMs) || options.deadlineMs <= 0) { + throw new Error('deadlineMs must be a positive integer'); + } + if ( + options.signal === null || + typeof options.signal !== 'object' || + typeof options.signal.aborted !== 'boolean' || + typeof options.signal.addEventListener !== 'function' || + typeof options.signal.removeEventListener !== 'function' + ) { + throw new Error('signal must be an AbortSignal'); + } +} + +export function errorName(error: unknown): string { + if (error !== null && typeof error === 'object' && 'name' in error && typeof error.name === 'string') { + return error.name; + } + return 'UnknownError'; +} + +function httpStatus(error: unknown): number | undefined { + if ( + error !== null && + typeof error === 'object' && + '$metadata' in error && + error.$metadata !== null && + typeof error.$metadata === 'object' && + 'httpStatusCode' in error.$metadata && + typeof error.$metadata.httpStatusCode === 'number' + ) { + return error.$metadata.httpStatusCode; + } + return undefined; +} + +export function isRetryableProviderError(error: unknown): boolean { + const status = httpStatus(error); + return ( + error instanceof RunnerConfigCallDeadlineError || + RETRYABLE_ERROR_NAMES.has(errorName(error)) || + (status !== undefined && status >= 500) + ); +} + +export function delay(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(new Error('runner configuration consumption was cancelled')); + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = (): void => signal.removeEventListener('abort', cancel); + const finish = (): void => { + if (settled) return; + settled = true; + cleanup(); + resolve(); + }; + const timer = setTimeout(finish, ms); + const cancel = (): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + cleanup(); + reject(new Error('runner configuration consumption was cancelled')); + }; + signal.addEventListener('abort', cancel, { once: true }); + }); +} + +export async function withCallDeadline( + parentSignal: AbortSignal, + deadlineMs: number, + callTimeoutMs: number, + operation: (signal: AbortSignal) => Promise, +): Promise { + if (parentSignal.aborted) throw new Error('runner configuration consumption was cancelled'); + const remaining = deadlineMs - Date.now(); + if (remaining <= 0) throw new RunnerConfigCallDeadlineError(); + + const controller = new AbortController(); + let cancel!: () => void; + let timeout: ReturnType | undefined; + const deadline = new Promise((_resolve, reject) => { + cancel = (): void => { + reject(new Error('runner configuration consumption was cancelled')); + controller.abort(); + }; + parentSignal.addEventListener('abort', cancel, { once: true }); + timeout = setTimeout( + () => { + reject(new RunnerConfigCallDeadlineError()); + controller.abort(); + }, + Math.max(1, Math.min(remaining, callTimeoutMs)), + ); + }); + + try { + return await Promise.race([operation(controller.signal), deadline]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + parentSignal.removeEventListener('abort', cancel); + } +} + +export function throwIfCancelled(signal: AbortSignal): void { + if (signal.aborted) throw new Error('runner configuration consumption was cancelled'); +} diff --git a/lambdas/libs/storage-providers/aws/ssm/runner-config-store.ts b/lambdas/libs/storage-providers/aws/ssm/runner-config-store.ts index 928e5f8f3d..80941822a5 100644 --- a/lambdas/libs/storage-providers/aws/ssm/runner-config-store.ts +++ b/lambdas/libs/storage-providers/aws/ssm/runner-config-store.ts @@ -4,12 +4,20 @@ import type { RunnerConfigMetadata, RunnerConfigRecord, RunnerConfigStore } from import type {} from './environment'; import { loadSsmParameterStoreTagsFromEnvironment } from './parameter-store-tags'; -interface AwsSsmRunnerConfigStoreConfig { +export interface AwsSsmRunnerConfigStoreConfig { tokenPath: string; - parameterStoreTags: { Key: string; Value: string }[]; + parameterStoreTags: ReadonlyArray>; } -export function createAwsSsmRunnerConfigStore(): RunnerConfigStore { +export function createAwsSsmRunnerConfigStore(config?: AwsSsmRunnerConfigStoreConfig): RunnerConfigStore { + if (config) { + return new AwsSsmRunnerConfigStore( + Object.freeze({ + ...config, + parameterStoreTags: Object.freeze(config.parameterStoreTags.map((tag) => Object.freeze({ ...tag }))), + }), + ); + } const tokenPath = process.env.SSM_TOKEN_PATH; if (!tokenPath || tokenPath.trim() === '') { throw new Error('Environment variable SSM_TOKEN_PATH is not set'); diff --git a/lambdas/libs/storage-providers/aws/ssm/runner-group-cache-store.ts b/lambdas/libs/storage-providers/aws/ssm/runner-group-cache-store.ts index 6f5a84ed45..c82fc8a182 100644 --- a/lambdas/libs/storage-providers/aws/ssm/runner-group-cache-store.ts +++ b/lambdas/libs/storage-providers/aws/ssm/runner-group-cache-store.ts @@ -4,12 +4,20 @@ import type { RunnerGroupCacheRecord, RunnerGroupCacheStore } from '../../core'; import type {} from './environment'; import { loadSsmParameterStoreTagsFromEnvironment } from './parameter-store-tags'; -interface AwsSsmRunnerGroupCacheStoreConfig { +export interface AwsSsmRunnerGroupCacheStoreConfig { configPath: string; - parameterStoreTags: { Key: string; Value: string }[]; + parameterStoreTags: ReadonlyArray>; } -export function createAwsSsmRunnerGroupCacheStore(): RunnerGroupCacheStore { +export function createAwsSsmRunnerGroupCacheStore(config?: AwsSsmRunnerGroupCacheStoreConfig): RunnerGroupCacheStore { + if (config) { + return new AwsSsmRunnerGroupCacheStore( + Object.freeze({ + ...config, + parameterStoreTags: Object.freeze(config.parameterStoreTags.map((tag) => Object.freeze({ ...tag }))), + }), + ); + } const configPath = process.env.SSM_CONFIG_PATH; if (!configPath || configPath.trim() === '') { throw new Error('Environment variable SSM_CONFIG_PATH is not set'); @@ -42,7 +50,7 @@ class AwsSsmRunnerGroupCacheStore implements RunnerGroupCacheStore { async create(record: RunnerGroupCacheRecord): Promise { await putParameter(this.parameterName(record.runnerGroupName), record.runnerGroupId.toString(), false, { - tags: this.config.parameterStoreTags, + tags: [...this.config.parameterStoreTags], }); } diff --git a/lambdas/libs/storage-providers/core/index.ts b/lambdas/libs/storage-providers/core/index.ts index 3e79ad92a7..27339a2159 100644 --- a/lambdas/libs/storage-providers/core/index.ts +++ b/lambdas/libs/storage-providers/core/index.ts @@ -37,6 +37,18 @@ export interface RunnerConfigConsumer { consume(runnerId: string, options: RunnerConfigConsumeOptions): Promise; } +export interface RunnerConfigStorage { + runnerConfig: RunnerConfigStore; + runnerGroupCache: RunnerGroupCacheStore; + consumer: RunnerConfigConsumer; +} + +export interface CommonStorage { + githubAppCredentials: GitHubAppCredentialsStore; +} + +export type StorageProviders = RunnerConfigStorage & CommonStorage; + export interface RunnerGroupCacheRecord { runnerGroupName: string; runnerGroupId: number; diff --git a/lambdas/libs/storage-providers/github-app-credentials.ts b/lambdas/libs/storage-providers/github-app-credentials.ts deleted file mode 100644 index fe89840455..0000000000 --- a/lambdas/libs/storage-providers/github-app-credentials.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createAwsSsmGitHubAppCredentialsStore } from './aws/ssm/github-app-credentials-store'; -import type { GitHubAppCredentialsStore } from './core'; - -export function createGitHubAppCredentialsStore(): GitHubAppCredentialsStore { - return createAwsSsmGitHubAppCredentialsStore(); -} diff --git a/lambdas/libs/storage-providers/index.ts b/lambdas/libs/storage-providers/index.ts index 36ceec3977..91aea6910d 100644 --- a/lambdas/libs/storage-providers/index.ts +++ b/lambdas/libs/storage-providers/index.ts @@ -10,8 +10,9 @@ export type { RunnerGroupCacheRecord, RunnerGroupCacheStore, } from './core'; -export { createGitHubAppCredentialsStore } from './github-app-credentials'; export { createRunnerConfigHousekeeper } from './runner-config-housekeeper'; -export { createRunnerConfigStore } from './runner-config'; -export { createRunnerGroupCacheStore } from './runner-group-cache'; export { createRunnerConfigConsumer, type RunnerConfigConsumerConfig } from './runner-config-consumer'; +export { resolveRunnerConfigStorageProvider, runnerConfigStorageProviders } from './provider'; +export type { RunnerConfigStorageProvider } from './provider'; +export { createCommonStorage, createStorageProviders } from './storage-providers'; +export type { StorageProviders, RunnerConfigStorage, CommonStorage } from './core'; diff --git a/lambdas/libs/storage-providers/provider.test.ts b/lambdas/libs/storage-providers/provider.test.ts new file mode 100644 index 0000000000..173b0199a5 --- /dev/null +++ b/lambdas/libs/storage-providers/provider.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveRunnerConfigStorageProvider } from './provider'; + +describe('resolveRunnerConfigStorageProvider', () => { + it.each([undefined, '', ' ', 'aws_ssm', 'AWS_SSM'])('resolves %j to aws_ssm', (value) => { + expect(resolveRunnerConfigStorageProvider(value)).toBe('aws_ssm'); + }); + + it.each([null, 'dynamodb', 'aws-ssm'])('rejects unsupported providers: %j', (value) => { + expect(() => resolveRunnerConfigStorageProvider(value)).toThrow( + `Unsupported runner config storage provider '${value}'`, + ); + }); +}); diff --git a/lambdas/libs/storage-providers/provider.ts b/lambdas/libs/storage-providers/provider.ts new file mode 100644 index 0000000000..1ade3e414c --- /dev/null +++ b/lambdas/libs/storage-providers/provider.ts @@ -0,0 +1,16 @@ +export const runnerConfigStorageProviders = ['aws_ssm'] as const; + +export type RunnerConfigStorageProvider = (typeof runnerConfigStorageProviders)[number]; + +export function resolveRunnerConfigStorageProvider(value: unknown): RunnerConfigStorageProvider { + if (value === undefined || (typeof value === 'string' && value.trim() === '')) { + return 'aws_ssm'; + } + if ( + typeof value !== 'string' || + !runnerConfigStorageProviders.includes(value.trim().toLowerCase() as RunnerConfigStorageProvider) + ) { + throw new Error(`Unsupported runner config storage provider '${String(value)}'`); + } + return value.trim().toLowerCase() as RunnerConfigStorageProvider; +} diff --git a/lambdas/libs/storage-providers/runner-config-consumer-common.ts b/lambdas/libs/storage-providers/runner-config-consumer-common.ts deleted file mode 100644 index 114bf38ad4..0000000000 --- a/lambdas/libs/storage-providers/runner-config-consumer-common.ts +++ /dev/null @@ -1,220 +0,0 @@ -import type { RunnerConfigConsumeOptions } from './core'; - -export const DEFAULT_CALL_TIMEOUT_MS = 5_000; -export const DEFAULT_CONFIG_TIMEOUT_MS = 40_000; -export const DEFAULT_POLL_INTERVAL_MS = 2_000; - -const RUNNER_ID_PATTERN = /^[A-Za-z0-9_.-]{1,256}$/; -const SSM_PARAMETER_PATH_PATTERN = /^\/[A-Za-z0-9_.\-/]+$/; -// AWS counts the partition/region/account ARN prefix toward its 1,011-character -// limit. Leave ample room for that deployment-specific prefix. -const MAX_SSM_PARAMETER_NAME_LENGTH = 900; - -const RETRYABLE_ERROR_NAMES = new Set([ - 'AbortError', - 'ConnectionError', - 'InternalServerException', - 'ProvisionedThroughputExceededException', - 'RequestLimitExceeded', - 'RequestTimeout', - 'ServiceUnavailable', - 'ThrottlingException', - 'TimeoutError', -]); - -export interface RunnerConfigPollingOptions { - callTimeoutMs?: number; - configTimeoutMs?: number; - pollIntervalMs?: number; -} - -export interface ResolvedRunnerConfigPollingOptions { - callTimeoutMs: number; - configTimeoutMs: number; - pollIntervalMs: number; -} - -class RunnerConfigCallDeadlineError extends Error { - public constructor() { - super('runner configuration provider call exceeded its deadline'); - this.name = 'RunnerConfigCallDeadlineError'; - } -} - -export function resolvePollingOptions(options: RunnerConfigPollingOptions): ResolvedRunnerConfigPollingOptions { - return { - callTimeoutMs: positiveIntegerOption('callTimeoutMs', options.callTimeoutMs, DEFAULT_CALL_TIMEOUT_MS), - configTimeoutMs: positiveIntegerOption('configTimeoutMs', options.configTimeoutMs, DEFAULT_CONFIG_TIMEOUT_MS), - pollIntervalMs: positiveIntegerOption('pollIntervalMs', options.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS), - }; -} - -export function positiveIntegerOption(name: string, value: number | undefined, fallback: number): number { - if (value === undefined) { - return fallback; - } - if (!Number.isSafeInteger(value) || value <= 0) { - throw new Error(`${name} must be a positive integer`); - } - return value; -} - -export function validateRunnerId(runnerId: string): void { - if (!RUNNER_ID_PATTERN.test(runnerId)) { - throw new Error('runnerId is invalid'); - } -} - -export function canonicalSsmTokenPath(tokenPath: string): string { - if (tokenPath.includes('//')) { - throw new Error('aws_ssm tokenPath is invalid'); - } - const canonical = tokenPath.endsWith('/') ? tokenPath.slice(0, -1) : tokenPath; - const segments = canonical.split('/').slice(1); - if ( - canonical.length === 0 || - canonical.length > MAX_SSM_PARAMETER_NAME_LENGTH || - !SSM_PARAMETER_PATH_PATTERN.test(canonical) || - segments.length > 14 || - segments.some((segment) => segment === '' || segment === '.' || segment === '..') || - /^(aws|ssm)/i.test(segments[0] ?? '') - ) { - throw new Error('aws_ssm tokenPath is invalid'); - } - return canonical; -} - -export function composeSsmParameterName(tokenPath: string, runnerId: string): string { - validateRunnerId(runnerId); - const parameterName = `${canonicalSsmTokenPath(tokenPath)}/${runnerId}`; - const segments = parameterName.split('/').slice(1); - if (parameterName.length > MAX_SSM_PARAMETER_NAME_LENGTH || segments.length > 15) { - throw new Error('aws_ssm runner configuration key is invalid'); - } - return parameterName; -} - -export function validateConsumeOptions(options: RunnerConfigConsumeOptions): void { - if (!Number.isSafeInteger(options.deadlineMs) || options.deadlineMs <= 0) { - throw new Error('deadlineMs must be a positive integer'); - } - if ( - options.signal === null || - typeof options.signal !== 'object' || - typeof options.signal.aborted !== 'boolean' || - typeof options.signal.addEventListener !== 'function' || - typeof options.signal.removeEventListener !== 'function' - ) { - throw new Error('signal must be an AbortSignal'); - } -} - -export function errorName(error: unknown): string { - if (error !== null && typeof error === 'object' && 'name' in error && typeof error.name === 'string') { - return error.name; - } - return 'UnknownError'; -} - -function httpStatus(error: unknown): number | undefined { - if ( - error !== null && - typeof error === 'object' && - '$metadata' in error && - error.$metadata !== null && - typeof error.$metadata === 'object' && - 'httpStatusCode' in error.$metadata && - typeof error.$metadata.httpStatusCode === 'number' - ) { - return error.$metadata.httpStatusCode; - } - return undefined; -} - -export function isRetryableProviderError(error: unknown): boolean { - const status = httpStatus(error); - return ( - error instanceof RunnerConfigCallDeadlineError || - RETRYABLE_ERROR_NAMES.has(errorName(error)) || - (status !== undefined && status >= 500) - ); -} - -export function delay(ms: number, signal: AbortSignal): Promise { - if (signal.aborted) { - return Promise.reject(new Error('runner configuration consumption was cancelled')); - } - - return new Promise((resolve, reject) => { - let settled = false; - const cleanup = (): void => signal.removeEventListener('abort', cancel); - const finish = (): void => { - if (settled) { - return; - } - settled = true; - cleanup(); - resolve(); - }; - const timer = setTimeout(finish, ms); - const cancel = (): void => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - cleanup(); - reject(new Error('runner configuration consumption was cancelled')); - }; - signal.addEventListener('abort', cancel, { once: true }); - }); -} - -export async function withCallDeadline( - parentSignal: AbortSignal, - deadlineMs: number, - callTimeoutMs: number, - operation: (signal: AbortSignal) => Promise, -): Promise { - if (parentSignal.aborted) { - throw new Error('runner configuration consumption was cancelled'); - } - - const remaining = deadlineMs - Date.now(); - if (remaining <= 0) { - throw new RunnerConfigCallDeadlineError(); - } - - const controller = new AbortController(); - let cancel!: () => void; - let timeout: ReturnType | undefined; - const deadline = new Promise((_resolve, reject) => { - cancel = (): void => { - reject(new Error('runner configuration consumption was cancelled')); - controller.abort(); - }; - parentSignal.addEventListener('abort', cancel, { once: true }); - timeout = setTimeout( - () => { - reject(new RunnerConfigCallDeadlineError()); - controller.abort(); - }, - Math.max(1, Math.min(remaining, callTimeoutMs)), - ); - }); - - try { - return await Promise.race([operation(controller.signal), deadline]); - } finally { - if (timeout !== undefined) { - clearTimeout(timeout); - } - parentSignal.removeEventListener('abort', cancel); - } -} - -export function throwIfCancelled(signal: AbortSignal): void { - if (signal.aborted) { - throw new Error('runner configuration consumption was cancelled'); - } -} diff --git a/lambdas/libs/storage-providers/runner-config-consumer.ts b/lambdas/libs/storage-providers/runner-config-consumer.ts index 1dc14ae7ba..fa7732fc4b 100644 --- a/lambdas/libs/storage-providers/runner-config-consumer.ts +++ b/lambdas/libs/storage-providers/runner-config-consumer.ts @@ -1,6 +1,6 @@ import { createAwsSsmRunnerConfigConsumer, type AwsSsmRunnerConfigApi } from './aws/ssm/runner-config-consumer'; import type { RunnerConfigConsumer } from './core'; -import { canonicalSsmTokenPath, type RunnerConfigPollingOptions } from './runner-config-consumer-common'; +import { canonicalSsmTokenPath, type RunnerConfigPollingOptions } from './aws/ssm/runner-config-consumer-common'; export type { RunnerConfigConsumeOptions, RunnerConfigConsumer } from './core'; export type { AwsSsmRunnerConfigApi } from './aws/ssm/runner-config-consumer'; diff --git a/lambdas/libs/storage-providers/runner-config.test.ts b/lambdas/libs/storage-providers/runner-config.test.ts deleted file mode 100644 index 4ae4bd6351..0000000000 --- a/lambdas/libs/storage-providers/runner-config.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { createAwsSsmRunnerConfigStore } from './aws/ssm/runner-config-store'; -import { createRunnerConfigStore } from './runner-config'; - -vi.mock('./aws/ssm/runner-config-store', () => ({ - createAwsSsmRunnerConfigStore: vi.fn(), -})); - -describe('runner config store factory', () => { - it('creates the SSM implementation while the provider seam is being introduced', () => { - const store = { create: vi.fn() }; - vi.mocked(createAwsSsmRunnerConfigStore).mockReturnValue(store); - - expect(createRunnerConfigStore()).toBe(store); - expect(createAwsSsmRunnerConfigStore).toHaveBeenCalledOnce(); - }); -}); diff --git a/lambdas/libs/storage-providers/runner-config.ts b/lambdas/libs/storage-providers/runner-config.ts deleted file mode 100644 index 65fe0e1ee3..0000000000 --- a/lambdas/libs/storage-providers/runner-config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createAwsSsmRunnerConfigStore } from './aws/ssm/runner-config-store'; -import type { RunnerConfigStore } from './core'; -import type {} from './environment'; - -export function createRunnerConfigStore(): RunnerConfigStore { - return createAwsSsmRunnerConfigStore(); -} diff --git a/lambdas/libs/storage-providers/runner-group-cache.ts b/lambdas/libs/storage-providers/runner-group-cache.ts deleted file mode 100644 index 40721df5b6..0000000000 --- a/lambdas/libs/storage-providers/runner-group-cache.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createAwsSsmRunnerGroupCacheStore } from './aws/ssm/runner-group-cache-store'; -import type { RunnerGroupCacheStore } from './core'; - -export function createRunnerGroupCacheStore(): RunnerGroupCacheStore { - return createAwsSsmRunnerGroupCacheStore(); -} diff --git a/lambdas/libs/storage-providers/storage-providers.test.ts b/lambdas/libs/storage-providers/storage-providers.test.ts new file mode 100644 index 0000000000..f1b01ee66b --- /dev/null +++ b/lambdas/libs/storage-providers/storage-providers.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createStorageProviders } from './storage-providers'; + +vi.mock('./aws/ssm/runner-config-store', () => ({ + createAwsSsmRunnerConfigStore: vi.fn(() => ({ create: vi.fn() })), +})); +vi.mock('./aws/ssm/runner-group-cache-store', () => ({ + createAwsSsmRunnerGroupCacheStore: vi.fn(() => ({ get: vi.fn(), create: vi.fn() })), +})); +vi.mock('./aws/ssm/runner-config-consumer', () => ({ + createAwsSsmRunnerConfigConsumer: vi.fn(() => ({ consume: vi.fn() })), +})); +vi.mock('./aws/ssm/github-app-credentials-store', () => ({ + createAwsSsmGitHubAppCredentialsStore: vi.fn(() => ({ get: vi.fn() })), +})); + +describe('createStorageProviders', () => { + it('parses environment once and composes independent runner and common capabilities', () => { + const environment = Object.freeze({ + RUNNER_CONFIG_STORAGE_PROVIDER: 'AWS_SSM', + SSM_TOKEN_PATH: '/runners/tokens', + SSM_CONFIG_PATH: '/runners/config', + SSM_PARAMETER_STORE_TAGS: JSON.stringify([{ Key: 'Environment', Value: 'test' }]), + PARAMETER_GITHUB_APP_ID_NAME: 'app-id', + PARAMETER_GITHUB_APP_KEY_BASE64_NAME: 'app-key', + AWS_SDK_CALL_TIMEOUT_SECONDS: '7', + RUNNER_CONFIG_TIMEOUT_SECONDS: '11', + RUNNER_CONFIG_POLL_SECONDS: '2', + RUNNER_CONFIG_DELETE_ATTEMPTS: '4', + }); + + const storage = createStorageProviders(environment); + + expect(storage).toEqual({ + runnerConfig: expect.any(Object), + runnerGroupCache: expect.any(Object), + consumer: expect.any(Object), + githubAppCredentials: expect.any(Object), + }); + }); +}); diff --git a/lambdas/libs/storage-providers/storage-providers.ts b/lambdas/libs/storage-providers/storage-providers.ts new file mode 100644 index 0000000000..e301ac2bb2 --- /dev/null +++ b/lambdas/libs/storage-providers/storage-providers.ts @@ -0,0 +1,45 @@ +import { createAwsSsmGitHubAppCredentialsStore } from './aws/ssm/github-app-credentials-store'; +import { createAwsSsmRunnerConfigConsumer } from './aws/ssm/runner-config-consumer'; +import { createAwsSsmRunnerConfigStore } from './aws/ssm/runner-config-store'; +import { createAwsSsmRunnerGroupCacheStore } from './aws/ssm/runner-group-cache-store'; +import type { CommonStorage, StorageProviders } from './core'; +import { loadRunnerConfigConsumerConfigFromEnvironment } from './runner-config-consumer'; +import { loadSsmParameterStoreTagsFromEnvironment } from './aws/ssm/parameter-store-tags'; +import { resolveRunnerConfigStorageProvider } from './provider'; + +type Environment = Readonly>; + +export function createStorageProviders(environment: Environment = process.env): StorageProviders { + const provider = resolveRunnerConfigStorageProvider(environment.RUNNER_CONFIG_STORAGE_PROVIDER); + if (provider !== 'aws_ssm') { + throw new Error(`Unsupported runner config storage provider '${provider}'`); + } + + const tokenPath = required(environment.SSM_TOKEN_PATH, 'SSM_TOKEN_PATH'); + const configPath = required(environment.SSM_CONFIG_PATH, 'SSM_CONFIG_PATH'); + const parameterStoreTags = loadSsmParameterStoreTagsFromEnvironment(environment); + const consumerConfig = loadRunnerConfigConsumerConfigFromEnvironment(environment); + + return { + runnerConfig: createAwsSsmRunnerConfigStore({ tokenPath, parameterStoreTags }), + runnerGroupCache: createAwsSsmRunnerGroupCacheStore({ configPath, parameterStoreTags }), + consumer: createAwsSsmRunnerConfigConsumer( + { SSM_TOKEN_PATH: tokenPath }, + consumerConfig, + ), + ...createCommonStorage(environment), + }; +} + +export function createCommonStorage(environment: Environment = process.env): CommonStorage { + return { + githubAppCredentials: createAwsSsmGitHubAppCredentialsStore(environment), + }; +} + +function required(value: string | undefined, name: string): string { + if (!value || value.trim() === '') { + throw new Error(`Environment variable ${name} is not set`); + } + return value; +} diff --git a/lambdas/libs/storage-providers/vitest.config.ts b/lambdas/libs/storage-providers/vitest.config.ts index 306d14e01b..20c739f253 100644 --- a/lambdas/libs/storage-providers/vitest.config.ts +++ b/lambdas/libs/storage-providers/vitest.config.ts @@ -9,12 +9,10 @@ export default mergeConfig(defaultConfig, { coverage: { include: [ 'index.ts', - 'github-app-credentials.ts', - 'runner-config.ts', 'runner-config-housekeeper.ts', 'runner-config-consumer.ts', - 'runner-config-consumer-common.ts', - 'runner-group-cache.ts', + 'storage-providers.ts', + 'provider.ts', 'core/**/*.ts', 'aws/**/*.ts', ], From c3b006b3f555eb4e11b42ff4eed5cef69e5b63e6 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 4 Sep 2026 13:42:45 +0200 Subject: [PATCH 2/6] fix(storage): format provider selection changes --- lambdas/functions/control-plane/src/pool/pool.ts | 15 ++------------- .../libs/storage-providers/storage-providers.ts | 5 +---- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/lambdas/functions/control-plane/src/pool/pool.ts b/lambdas/functions/control-plane/src/pool/pool.ts index 5c7f28a6fc..c02787426f 100644 --- a/lambdas/functions/control-plane/src/pool/pool.ts +++ b/lambdas/functions/control-plane/src/pool/pool.ts @@ -49,19 +49,8 @@ export async function adjust(event: PoolEvent): Promise { const ghAppAuth = await createGithubAppAuth(undefined, ghesApiUrl, undefined, storage.githubAppCredentials); const appIdx = ghAppAuth.appIndex; - const installationId = await getInstallationId( - ghAppAuth.token, - ghesApiUrl, - runnerOwner, - appIdx, - storage, - ); - const ghAuth = await createGithubInstallationAuth( - installationId, - ghesApiUrl, - appIdx, - storage.githubAppCredentials, - ); + const installationId = await getInstallationId(ghAppAuth.token, ghesApiUrl, runnerOwner, appIdx, storage); + const ghAuth = await createGithubInstallationAuth(installationId, ghesApiUrl, appIdx, storage.githubAppCredentials); const githubInstallationClient = await createOctokitClient(ghAuth.token, ghesApiUrl); // Get statuses of runners registered in GitHub diff --git a/lambdas/libs/storage-providers/storage-providers.ts b/lambdas/libs/storage-providers/storage-providers.ts index e301ac2bb2..2cbe89ed57 100644 --- a/lambdas/libs/storage-providers/storage-providers.ts +++ b/lambdas/libs/storage-providers/storage-providers.ts @@ -23,10 +23,7 @@ export function createStorageProviders(environment: Environment = process.env): return { runnerConfig: createAwsSsmRunnerConfigStore({ tokenPath, parameterStoreTags }), runnerGroupCache: createAwsSsmRunnerGroupCacheStore({ configPath, parameterStoreTags }), - consumer: createAwsSsmRunnerConfigConsumer( - { SSM_TOKEN_PATH: tokenPath }, - consumerConfig, - ), + consumer: createAwsSsmRunnerConfigConsumer({ SSM_TOKEN_PATH: tokenPath }, consumerConfig), ...createCommonStorage(environment), }; } From fab9594291d92c4c6d6a5bf418981b59b3b5d63d Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 4 Sep 2026 14:03:08 +0200 Subject: [PATCH 3/6] fix(control-plane): align storage provider test fixtures --- .../control-plane/src/scale-runners/scale-up-contract.test.ts | 3 +++ 1 file changed, 3 insertions(+) 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 index 7c381c1526..d7a025fd88 100644 --- 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 @@ -57,6 +57,9 @@ beforeEach(() => { vi.clearAllMocks(); process.env = { ...cleanEnv }; process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/tokens'; + process.env.SSM_CONFIG_PATH = '/github-action-runners/default/runners/config'; + process.env.PARAMETER_GITHUB_APP_ID_NAME = 'github-app-id'; + process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = 'github-app-key'; mockedAppAuth.mockResolvedValue({ type: 'app', token: 'app-token', appId: 1, expiresAt: 'some-date' }); mockedInstallationAuth.mockResolvedValue({ From b8d3e4e6e71a451ec52550a176ee3594be344aba Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 4 Sep 2026 14:38:33 +0200 Subject: [PATCH 4/6] test(compute-providers): cover storage capability argument --- .../compute-providers/aws/ec2/src/control-plane/pool.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/pool.test.ts b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/pool.test.ts index bd4f7c53a7..3f8df82173 100644 --- a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/pool.test.ts +++ b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/pool.test.ts @@ -156,6 +156,7 @@ describe('createEc2PoolCapability.createRunners', () => { githubInstallationClient, createStartRunnerConfig, 'pool-lambda', + undefined, ); }); }); From a6df5c5d24925e21d7bc6fd51ccf62b3dbf44f36 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 4 Sep 2026 14:14:28 +0200 Subject: [PATCH 5/6] fix(auth): update credentials store test expectations --- .../control-plane/src/pool/pool.test.ts | 2 +- .../src/scale-runners/scale-up.test.ts | 30 +++++++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/lambdas/functions/control-plane/src/pool/pool.test.ts b/lambdas/functions/control-plane/src/pool/pool.test.ts index ccaad541a0..5253fd5147 100644 --- a/lambdas/functions/control-plane/src/pool/pool.test.ts +++ b/lambdas/functions/control-plane/src/pool/pool.test.ts @@ -327,7 +327,7 @@ describe('pool adjustment', () => { await adjust({ poolSize: 3 }); - expect(mockedInstallationAuth).toHaveBeenCalledWith(expect.any(Number), expect.any(String), 1); + expect(mockedInstallationAuth).toHaveBeenCalledWith(expect.any(Number), expect.any(String), 1, expect.anything()); }); it('looks up installationId using the selected app JWT', async () => { diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts index 1ca7536b2a..d406c7c5f5 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts @@ -1033,8 +1033,18 @@ describe('scaleUp with GHES', () => { await scaleUpModule.scaleUp(messages); expect(mockCreateClient).toHaveBeenCalledTimes(3); // 1 app client, 2 repo installation clients - expect(mockedInstallationAuth).toHaveBeenCalledWith(100, 'https://github.enterprise.something/api/v3', 0); - expect(mockedInstallationAuth).toHaveBeenCalledWith(200, 'https://github.enterprise.something/api/v3', 0); + expect(mockedInstallationAuth).toHaveBeenCalledWith( + 100, + 'https://github.enterprise.something/api/v3', + 0, + expect.anything(), + ); + expect(mockedInstallationAuth).toHaveBeenCalledWith( + 200, + 'https://github.enterprise.something/api/v3', + 0, + expect.anything(), + ); }); it('Should reuse GitHub clients for same installation', async () => { @@ -1467,8 +1477,8 @@ describe('scaleUp with public GH', () => { await scaleUpModule.scaleUp(messages); expect(mockCreateClient).toHaveBeenCalledTimes(3); // 1 app client, 2 repo installation clients - expect(mockedInstallationAuth).toHaveBeenCalledWith(100, '', 0); - expect(mockedInstallationAuth).toHaveBeenCalledWith(200, '', 0); + expect(mockedInstallationAuth).toHaveBeenCalledWith(100, '', 0, expect.anything()); + expect(mockedInstallationAuth).toHaveBeenCalledWith(200, '', 0, expect.anything()); }); it('Should reuse GitHub clients for same installation', async () => { @@ -1945,8 +1955,8 @@ describe('scaleUp with Github Data Residency', () => { await scaleUpModule.scaleUp(messages); expect(mockCreateClient).toHaveBeenCalledTimes(3); // 1 app client, 2 repo installation clients - expect(mockedInstallationAuth).toHaveBeenCalledWith(100, '', 0); - expect(mockedInstallationAuth).toHaveBeenCalledWith(200, '', 0); + expect(mockedInstallationAuth).toHaveBeenCalledWith(100, '', 0, expect.anything()); + expect(mockedInstallationAuth).toHaveBeenCalledWith(200, '', 0, expect.anything()); }); it('Should reuse GitHub clients for same installation', async () => { @@ -2209,6 +2219,7 @@ describe('Multi-app round-robin', () => { expect.any(Number), expect.any(String), 1, // appIndex must match the one from createGithubAppAuth + expect.anything(), ); }); @@ -2234,6 +2245,7 @@ describe('Multi-app round-robin', () => { TEST_DATA_SINGLE.installationId, // from mockOctokit.apps.getOrgInstallation mock expect.any(String), 1, + expect.anything(), ); }); @@ -2251,7 +2263,7 @@ describe('Multi-app round-robin', () => { // Should use 999 from webhook directly — no API lookup expect(mockOctokit.apps.getOrgInstallation).not.toHaveBeenCalled(); - expect(mockedInstallationAuth).toHaveBeenCalledWith(999, expect.any(String), 0); + expect(mockedInstallationAuth).toHaveBeenCalledWith(999, expect.any(String), 0, expect.anything()); }); it('primary app (appIndex 0) reuses webhook installationId even in multi-app deployment', async () => { @@ -2270,7 +2282,7 @@ describe('Multi-app round-robin', () => { // Primary app must NOT do an API lookup — reuses webhook installationId expect(mockOctokit.apps.getOrgInstallation).not.toHaveBeenCalled(); - expect(mockedInstallationAuth).toHaveBeenCalledWith(999, expect.any(String), 0); + expect(mockedInstallationAuth).toHaveBeenCalledWith(999, expect.any(String), 0, expect.anything()); }); it('stored installationId takes precedence over webhook payload for additional app', async () => { @@ -2289,7 +2301,7 @@ describe('Multi-app round-robin', () => { // Stored id (77) wins — no API lookup needed expect(mockOctokit.apps.getOrgInstallation).not.toHaveBeenCalled(); - expect(mockedInstallationAuth).toHaveBeenCalledWith(77, expect.any(String), 1); + expect(mockedInstallationAuth).toHaveBeenCalledWith(77, expect.any(String), 1, expect.anything()); }); }); From d42c5d365fa2d3cd12fc756e6c9fb0eb0ca7e1f3 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Mon, 7 Sep 2026 14:45:30 +0200 Subject: [PATCH 6/6] refactor(storage): centralize provider identity --- lambdas/libs/storage-providers/index.ts | 6 +++++- lambdas/libs/storage-providers/provider.ts | 7 +++++-- .../libs/storage-providers/runner-config-consumer.ts | 11 ++++++----- lambdas/libs/storage-providers/storage-providers.ts | 12 ++++++++++-- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/lambdas/libs/storage-providers/index.ts b/lambdas/libs/storage-providers/index.ts index 91aea6910d..4b5f14f1c6 100644 --- a/lambdas/libs/storage-providers/index.ts +++ b/lambdas/libs/storage-providers/index.ts @@ -12,7 +12,11 @@ export type { } from './core'; export { createRunnerConfigHousekeeper } from './runner-config-housekeeper'; export { createRunnerConfigConsumer, type RunnerConfigConsumerConfig } from './runner-config-consumer'; -export { resolveRunnerConfigStorageProvider, runnerConfigStorageProviders } from './provider'; +export { + resolveRunnerConfigStorageProvider, + runnerConfigStorageProvider, + runnerConfigStorageProviders, +} from './provider'; export type { RunnerConfigStorageProvider } from './provider'; export { createCommonStorage, createStorageProviders } from './storage-providers'; export type { StorageProviders, RunnerConfigStorage, CommonStorage } from './core'; diff --git a/lambdas/libs/storage-providers/provider.ts b/lambdas/libs/storage-providers/provider.ts index 1ade3e414c..ff9ecb73cc 100644 --- a/lambdas/libs/storage-providers/provider.ts +++ b/lambdas/libs/storage-providers/provider.ts @@ -1,10 +1,13 @@ -export const runnerConfigStorageProviders = ['aws_ssm'] as const; +export const runnerConfigStorageProvider = { + awsSsm: 'aws_ssm', +} as const; +export const runnerConfigStorageProviders = [runnerConfigStorageProvider.awsSsm] as const; export type RunnerConfigStorageProvider = (typeof runnerConfigStorageProviders)[number]; export function resolveRunnerConfigStorageProvider(value: unknown): RunnerConfigStorageProvider { if (value === undefined || (typeof value === 'string' && value.trim() === '')) { - return 'aws_ssm'; + return runnerConfigStorageProvider.awsSsm; } if ( typeof value !== 'string' || diff --git a/lambdas/libs/storage-providers/runner-config-consumer.ts b/lambdas/libs/storage-providers/runner-config-consumer.ts index fa7732fc4b..62b805fc37 100644 --- a/lambdas/libs/storage-providers/runner-config-consumer.ts +++ b/lambdas/libs/storage-providers/runner-config-consumer.ts @@ -1,6 +1,7 @@ import { createAwsSsmRunnerConfigConsumer, type AwsSsmRunnerConfigApi } from './aws/ssm/runner-config-consumer'; import type { RunnerConfigConsumer } from './core'; import { canonicalSsmTokenPath, type RunnerConfigPollingOptions } from './aws/ssm/runner-config-consumer-common'; +import { runnerConfigStorageProvider } from './provider'; export type { RunnerConfigConsumeOptions, RunnerConfigConsumer } from './core'; export type { AwsSsmRunnerConfigApi } from './aws/ssm/runner-config-consumer'; @@ -13,7 +14,7 @@ export interface RunnerConfigConsumerConfig extends RunnerConfigPollingOptions { } export interface RunnerConfigStorageContext { - RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm'; + RUNNER_CONFIG_STORAGE_PROVIDER: typeof runnerConfigStorageProvider.awsSsm; SSM_TOKEN_PATH: string; } @@ -42,17 +43,17 @@ export function createRunnerConfigConsumer( export const createRunnerConfigConsumerFromEnvironment = createRunnerConfigConsumer; export function parseRunnerConfigStorageContext(value: unknown): RunnerConfigStorageContext { - if (!isPlainObject(value) || value.RUNNER_CONFIG_STORAGE_PROVIDER !== 'aws_ssm') { + if (!isPlainObject(value) || value.RUNNER_CONFIG_STORAGE_PROVIDER !== runnerConfigStorageProvider.awsSsm) { throw new Error('runner configuration storage context is invalid'); } if ( !hasExactKeys(value, ['RUNNER_CONFIG_STORAGE_PROVIDER', 'SSM_TOKEN_PATH']) || typeof value.SSM_TOKEN_PATH !== 'string' ) { - throw new Error('aws_ssm runner configuration storage context is invalid'); + throw new Error(`${runnerConfigStorageProvider.awsSsm} runner configuration storage context is invalid`); } return Object.freeze({ - RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', + RUNNER_CONFIG_STORAGE_PROVIDER: runnerConfigStorageProvider.awsSsm, SSM_TOKEN_PATH: canonicalSsmTokenPath(value.SSM_TOKEN_PATH), }); } @@ -61,7 +62,7 @@ export function loadRunnerConfigStorageContextFromEnvironment( environment: Environment = process.env, ): RunnerConfigStorageContext { return parseRunnerConfigStorageContext({ - RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', + RUNNER_CONFIG_STORAGE_PROVIDER: runnerConfigStorageProvider.awsSsm, SSM_TOKEN_PATH: environment.SSM_TOKEN_PATH, }); } diff --git a/lambdas/libs/storage-providers/storage-providers.ts b/lambdas/libs/storage-providers/storage-providers.ts index 2cbe89ed57..e2335ff375 100644 --- a/lambdas/libs/storage-providers/storage-providers.ts +++ b/lambdas/libs/storage-providers/storage-providers.ts @@ -1,3 +1,4 @@ +import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; import { createAwsSsmGitHubAppCredentialsStore } from './aws/ssm/github-app-credentials-store'; import { createAwsSsmRunnerConfigConsumer } from './aws/ssm/runner-config-consumer'; import { createAwsSsmRunnerConfigStore } from './aws/ssm/runner-config-store'; @@ -5,13 +6,15 @@ import { createAwsSsmRunnerGroupCacheStore } from './aws/ssm/runner-group-cache- import type { CommonStorage, StorageProviders } from './core'; import { loadRunnerConfigConsumerConfigFromEnvironment } from './runner-config-consumer'; import { loadSsmParameterStoreTagsFromEnvironment } from './aws/ssm/parameter-store-tags'; -import { resolveRunnerConfigStorageProvider } from './provider'; +import { resolveRunnerConfigStorageProvider, runnerConfigStorageProvider } from './provider'; type Environment = Readonly>; +const logger = createChildLogger('storage-providers'); + export function createStorageProviders(environment: Environment = process.env): StorageProviders { const provider = resolveRunnerConfigStorageProvider(environment.RUNNER_CONFIG_STORAGE_PROVIDER); - if (provider !== 'aws_ssm') { + if (provider !== runnerConfigStorageProvider.awsSsm) { throw new Error(`Unsupported runner config storage provider '${provider}'`); } @@ -20,6 +23,11 @@ export function createStorageProviders(environment: Environment = process.env): const parameterStoreTags = loadSsmParameterStoreTagsFromEnvironment(environment); const consumerConfig = loadRunnerConfigConsumerConfigFromEnvironment(environment); + logger.info('Composing runner configuration storage providers', { + storageProvider: provider, + capabilities: ['runnerConfig', 'runnerGroupCache', 'consumer', 'githubAppCredentials'], + }); + return { runnerConfig: createAwsSsmRunnerConfigStore({ tokenPath, parameterStoreTags }), runnerGroupCache: createAwsSsmRunnerGroupCacheStore({ configPath, parameterStoreTags }),