From 095e0ca2fb4c58ab3f9de76b1cdc91fd9d5bfc5f Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 4 Sep 2026 12:18:05 +0200 Subject: [PATCH 1/4] feat(storage): add runner config consumer --- .../aws/ssm/runner-config-consumer-common.ts | 14 ++ .../aws/ssm/runner-config-consumer.test.ts | 220 ++++++++++++++++++ .../aws/ssm/runner-config-consumer.ts | 163 +++++++++++++ lambdas/libs/storage-providers/core/index.ts | 10 + lambdas/libs/storage-providers/index.ts | 13 +- lambdas/libs/storage-providers/package.json | 3 +- .../runner-config-consumer-common.ts | 220 ++++++++++++++++++ .../runner-config-consumer-subpath.test.ts | 31 +++ .../runner-config-consumer.test.ts | 186 +++++++++++++++ .../runner-config-consumer.ts | 131 +++++++++++ .../libs/storage-providers/vitest.config.ts | 12 +- 11 files changed, 996 insertions(+), 7 deletions(-) create mode 100644 lambdas/libs/storage-providers/aws/ssm/runner-config-consumer-common.ts create mode 100644 lambdas/libs/storage-providers/aws/ssm/runner-config-consumer.test.ts create mode 100644 lambdas/libs/storage-providers/aws/ssm/runner-config-consumer.ts create mode 100644 lambdas/libs/storage-providers/runner-config-consumer-common.ts create mode 100644 lambdas/libs/storage-providers/runner-config-consumer-subpath.test.ts create mode 100644 lambdas/libs/storage-providers/runner-config-consumer.test.ts create mode 100644 lambdas/libs/storage-providers/runner-config-consumer.ts 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 new file mode 100644 index 0000000000..91dbfc696f --- /dev/null +++ b/lambdas/libs/storage-providers/aws/ssm/runner-config-consumer-common.ts @@ -0,0 +1,14 @@ +export { + canonicalSsmTokenPath, + composeSsmParameterName, + delay, + errorName, + isRetryableProviderError, + positiveIntegerOption, + resolvePollingOptions, + throwIfCancelled, + validateConsumeOptions, + withCallDeadline, + type ResolvedRunnerConfigPollingOptions, + type RunnerConfigPollingOptions, +} from '../../runner-config-consumer-common'; diff --git a/lambdas/libs/storage-providers/aws/ssm/runner-config-consumer.test.ts b/lambdas/libs/storage-providers/aws/ssm/runner-config-consumer.test.ts new file mode 100644 index 0000000000..2a5d2217ea --- /dev/null +++ b/lambdas/libs/storage-providers/aws/ssm/runner-config-consumer.test.ts @@ -0,0 +1,220 @@ +import { DeleteParameterCommand, GetParameterCommand, type SSMClient } from '@aws-sdk/client-ssm'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + AwsSdkSsmRunnerConfigApi, + createAwsSsmRunnerConfigConsumer, + type AwsSsmRunnerConfigApi, +} from './runner-config-consumer'; + +function namedError(name: string, message = 'provider detail'): Error { + const error = new Error(message); + error.name = name; + return error; +} + +describe('AWS SDK SSM runner config API', () => { + it('decrypts the parameter and deletes it with the caller abort signal', async () => { + const send = vi + .fn() + .mockResolvedValueOnce({ Parameter: { Value: 'encoded-jit' } }) + .mockResolvedValueOnce({}); + const api = new AwsSdkSsmRunnerConfigApi({ send } as unknown as SSMClient); + const signal = new AbortController().signal; + + await expect(api.getParameter('/runner/tokens/runner-123', signal)).resolves.toBe('encoded-jit'); + await expect(api.deleteParameter('/runner/tokens/runner-123', signal)).resolves.toBeUndefined(); + + expect(send.mock.calls[0][0]).toBeInstanceOf(GetParameterCommand); + expect(send.mock.calls[0][0].input).toEqual({ + Name: '/runner/tokens/runner-123', + WithDecryption: true, + }); + expect(send.mock.calls[0][1]).toEqual({ abortSignal: signal }); + expect(send.mock.calls[1][0]).toBeInstanceOf(DeleteParameterCommand); + expect(send.mock.calls[1][0].input).toEqual({ Name: '/runner/tokens/runner-123' }); + expect(send.mock.calls[1][1]).toEqual({ abortSignal: signal }); + }); +}); + +describe('SSM runner config consumer', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('polls a missing parameter, reads it, and deletes it before returning', async () => { + const getParameter = vi + .fn() + .mockRejectedValueOnce(namedError('ParameterNotFound')) + .mockResolvedValueOnce('encoded-jit'); + const deleteParameter = vi.fn().mockResolvedValue(undefined); + const consumer = createAwsSsmRunnerConfigConsumer( + { RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', SSM_TOKEN_PATH: '/runner/tokens' }, + { + api: { getParameter, deleteParameter }, + callTimeoutMs: 100, + configTimeoutMs: 500, + pollIntervalMs: 1, + }, + ); + + await expect( + consumer.consume('runner-123', { + deadlineMs: Date.now() + 1_000, + signal: new AbortController().signal, + }), + ).resolves.toBe('encoded-jit'); + expect(getParameter).toHaveBeenCalledTimes(2); + expect(deleteParameter).toHaveBeenCalledOnce(); + expect(deleteParameter).toHaveBeenCalledWith('/runner/tokens/runner-123', expect.any(AbortSignal)); + }); + + it('retries a transient delete failure without returning the value early', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + const api: AwsSsmRunnerConfigApi = { + getParameter: vi.fn().mockResolvedValue('encoded-jit'), + deleteParameter: vi + .fn() + .mockRejectedValueOnce(namedError('ThrottlingException')) + .mockResolvedValueOnce(undefined), + }; + const consumer = createAwsSsmRunnerConfigConsumer( + { RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', SSM_TOKEN_PATH: '/runner/tokens' }, + { api, callTimeoutMs: 100, configTimeoutMs: 2_000, deleteAttempts: 2, pollIntervalMs: 1 }, + ); + + const pending = consumer.consume('runner-123', { + deadlineMs: Date.now() + 3_000, + signal: new AbortController().signal, + }); + await vi.runAllTimersAsync(); + + await expect(pending).resolves.toBe('encoded-jit'); + expect(api.deleteParameter).toHaveBeenCalledTimes(2); + }); + + it('fails closed when another reader deletes the SSM parameter first', async () => { + const api: AwsSsmRunnerConfigApi = { + getParameter: vi.fn().mockResolvedValue('encoded-jit'), + deleteParameter: vi.fn().mockRejectedValue(namedError('ParameterNotFound')), + }; + const consumer = createAwsSsmRunnerConfigConsumer( + { RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', SSM_TOKEN_PATH: '/runner/tokens' }, + { api, callTimeoutMs: 100, configTimeoutMs: 100, deleteAttempts: 3, pollIntervalMs: 1 }, + ); + + await expect( + consumer.consume('runner-123', { + deadlineMs: Date.now() + 1_000, + signal: new AbortController().signal, + }), + ).rejects.toThrow('runner configuration could not be deleted from SSM'); + expect(api.deleteParameter).toHaveBeenCalledOnce(); + }); + + it('sanitizes non-retryable provider failures', async () => { + const api: AwsSsmRunnerConfigApi = { + getParameter: vi.fn().mockRejectedValue(namedError('AccessDeniedException', 'encoded-jit-secret')), + deleteParameter: vi.fn(), + }; + const consumer = createAwsSsmRunnerConfigConsumer( + { RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', SSM_TOKEN_PATH: '/runner/tokens' }, + { api, callTimeoutMs: 100, configTimeoutMs: 100, pollIntervalMs: 1 }, + ); + + const pending = consumer.consume('runner-123', { + deadlineMs: Date.now() + 1_000, + signal: new AbortController().signal, + }); + await expect(pending).rejects.toThrow('failed to read runner configuration from SSM'); + await expect(pending).rejects.not.toThrow('encoded-jit-secret'); + expect(api.deleteParameter).not.toHaveBeenCalled(); + }); + + it('rejects an empty SSM parameter value without attempting deletion', async () => { + const api: AwsSsmRunnerConfigApi = { + getParameter: vi.fn().mockResolvedValue(''), + deleteParameter: vi.fn(), + }; + const consumer = createAwsSsmRunnerConfigConsumer( + { RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', SSM_TOKEN_PATH: '/runner/tokens' }, + { api, callTimeoutMs: 100, configTimeoutMs: 100, pollIntervalMs: 1 }, + ); + + await expect( + consumer.consume('runner-123', { + deadlineMs: Date.now() + 1_000, + signal: new AbortController().signal, + }), + ).rejects.toThrow('failed to read runner configuration from SSM'); + expect(api.deleteParameter).not.toHaveBeenCalled(); + }); + + it('validates the full parameter name before calling SSM', async () => { + const api: AwsSsmRunnerConfigApi = { + getParameter: vi.fn(), + deleteParameter: vi.fn(), + }; + const consumer = createAwsSsmRunnerConfigConsumer( + { RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', SSM_TOKEN_PATH: `/${'x'.repeat(890)}` }, + { api, callTimeoutMs: 100, configTimeoutMs: 100, pollIntervalMs: 1 }, + ); + + await expect( + consumer.consume('runner-1234567890', { + deadlineMs: Date.now() + 1_000, + signal: new AbortController().signal, + }), + ).rejects.toThrow('aws_ssm runner configuration key is invalid'); + expect(api.getParameter).not.toHaveBeenCalled(); + }); + + it('stops a provider call immediately when the caller aborts', async () => { + const api: AwsSsmRunnerConfigApi = { + getParameter: vi.fn().mockReturnValue(new Promise(() => undefined)), + deleteParameter: vi.fn(), + }; + const controller = new AbortController(); + const consumer = createAwsSsmRunnerConfigConsumer( + { RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', SSM_TOKEN_PATH: '/runner/tokens' }, + { api, callTimeoutMs: 10_000, configTimeoutMs: 10_000, pollIntervalMs: 1 }, + ); + const pending = consumer.consume('runner-123', { + deadlineMs: Date.now() + 10_000, + signal: controller.signal, + }); + + controller.abort(); + + await expect(pending).rejects.toThrow('runner configuration consumption was cancelled'); + }); + + it('reserves a bounded delete attempt when the value appears near the polling deadline', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + const startedAt = Date.now(); + let deleteStartedAt: number | undefined; + const api: AwsSsmRunnerConfigApi = { + getParameter: vi.fn().mockResolvedValueOnce(undefined).mockResolvedValueOnce('encoded-jit'), + deleteParameter: vi.fn().mockImplementation(async () => { + deleteStartedAt = Date.now(); + }), + }; + const consumer = createAwsSsmRunnerConfigConsumer( + { RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', SSM_TOKEN_PATH: '/runner/tokens' }, + { api, callTimeoutMs: 100, configTimeoutMs: 1_000, pollIntervalMs: 99 }, + ); + + const pending = consumer.consume('runner-123', { + deadlineMs: startedAt + 200, + signal: new AbortController().signal, + }); + await vi.runAllTimersAsync(); + + await expect(pending).resolves.toBe('encoded-jit'); + expect(api.getParameter).toHaveBeenCalledTimes(2); + expect(deleteStartedAt).toBe(startedAt + 99); + expect(deleteStartedAt).toBeLessThanOrEqual(startedAt + 100); + }); +}); diff --git a/lambdas/libs/storage-providers/aws/ssm/runner-config-consumer.ts b/lambdas/libs/storage-providers/aws/ssm/runner-config-consumer.ts new file mode 100644 index 0000000000..da26a25402 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/ssm/runner-config-consumer.ts @@ -0,0 +1,163 @@ +import { DeleteParameterCommand, GetParameterCommand, SSMClient } from '@aws-sdk/client-ssm'; + +import type { RunnerConfigConsumer, RunnerConfigConsumeOptions } from '../../core'; +import { + composeSsmParameterName, + delay, + errorName, + isRetryableProviderError, + positiveIntegerOption, + resolvePollingOptions, + throwIfCancelled, + validateConsumeOptions, + withCallDeadline, + type RunnerConfigPollingOptions, +} from './runner-config-consumer-common'; + +const DEFAULT_DELETE_ATTEMPTS = 3; + +export interface AwsSsmRunnerConfigApi { + getParameter(name: string, signal: AbortSignal): Promise; + deleteParameter(name: string, signal: AbortSignal): Promise; +} + +export class AwsSdkSsmRunnerConfigApi implements AwsSsmRunnerConfigApi { + private client?: SSMClient; + + public constructor(client?: SSMClient) { + this.client = client; + } + + private getClient(): SSMClient { + // Lifecycle hooks can be snapshotted before their first request. Constructing + // the untraced client here avoids persisting connection state in that snapshot. + this.client ??= new SSMClient({ maxAttempts: 1 }); + return this.client; + } + + public async getParameter(name: string, signal: AbortSignal): Promise { + const response = await this.getClient().send(new GetParameterCommand({ Name: name, WithDecryption: true }), { + abortSignal: signal, + }); + return response.Parameter?.Value; + } + + public async deleteParameter(name: string, signal: AbortSignal): Promise { + await this.getClient().send(new DeleteParameterCommand({ Name: name }), { abortSignal: signal }); + } +} + +export interface AwsSsmRunnerConfigConsumerOptions extends RunnerConfigPollingOptions { + api?: AwsSsmRunnerConfigApi; + deleteAttempts?: number; +} + +export function createAwsSsmRunnerConfigConsumer( + environment: { SSM_TOKEN_PATH: string }, + options: AwsSsmRunnerConfigConsumerOptions = {}, +): RunnerConfigConsumer { + return new AwsSsmRunnerConfigConsumer( + environment.SSM_TOKEN_PATH, + options.api ?? new AwsSdkSsmRunnerConfigApi(), + options, + ); +} + +class AwsSsmRunnerConfigConsumer implements RunnerConfigConsumer { + private readonly callTimeoutMs: number; + private readonly configTimeoutMs: number; + private readonly deleteAttempts: number; + private readonly pollIntervalMs: number; + + public constructor( + private readonly tokenPath: string, + private readonly api: AwsSsmRunnerConfigApi, + options: AwsSsmRunnerConfigConsumerOptions, + ) { + const polling = resolvePollingOptions(options); + this.callTimeoutMs = polling.callTimeoutMs; + this.configTimeoutMs = polling.configTimeoutMs; + this.pollIntervalMs = polling.pollIntervalMs; + this.deleteAttempts = positiveIntegerOption('deleteAttempts', options.deleteAttempts, DEFAULT_DELETE_ATTEMPTS); + } + + public async consume(runnerId: string, options: RunnerConfigConsumeOptions): Promise { + validateConsumeOptions(options); + const parameterName = composeSsmParameterName(this.tokenPath, runnerId); + const startedAt = Date.now(); + const remainingMs = Math.max(0, options.deadlineMs - startedAt); + // Preserve enough of short hook budgets for at least one bounded delete + // attempt without reviving the old fixed reserve that could consume the + // entire polling window. + const deleteReserveMs = Math.min(this.callTimeoutMs, Math.max(1, Math.floor(remainingMs / 2))); + const pollDeadline = Math.min(startedAt + this.configTimeoutMs, options.deadlineMs - deleteReserveMs); + let runnerConfig: string | undefined; + + while (Date.now() < pollDeadline) { + throwIfCancelled(options.signal); + try { + runnerConfig = await this.read(parameterName, pollDeadline, options.signal); + if (runnerConfig !== undefined) { + if (runnerConfig.length === 0) { + throw new Error('runner configuration record has an invalid value'); + } + break; + } + } catch (error) { + if (options.signal.aborted) { + throw new Error('runner configuration consumption was cancelled'); + } + if (!isSsmNotFound(error) && !isRetryableProviderError(error)) { + throw new Error('failed to read runner configuration from SSM'); + } + } + + const remaining = pollDeadline - Date.now(); + if (remaining > 0) { + await delay(Math.min(this.pollIntervalMs, remaining), options.signal); + } + } + + if (runnerConfig === undefined) { + throw new Error('runner configuration did not become available before the deadline'); + } + + await this.delete(parameterName, options); + return runnerConfig; + } + + private async read(name: string, deadlineMs: number, signal: AbortSignal): Promise { + return withCallDeadline(signal, deadlineMs, this.callTimeoutMs, (callSignal) => + this.api.getParameter(name, callSignal), + ); + } + + private async delete(name: string, options: RunnerConfigConsumeOptions): Promise { + for (let attempt = 1; attempt <= this.deleteAttempts; attempt += 1) { + try { + await withCallDeadline(options.signal, options.deadlineMs, this.callTimeoutMs, (callSignal) => + this.api.deleteParameter(name, callSignal), + ); + return; + } catch (error) { + if (options.signal.aborted) { + throw new Error('runner configuration consumption was cancelled'); + } + if (!isRetryableProviderError(error) || attempt === this.deleteAttempts) { + break; + } + + const remaining = options.deadlineMs - Date.now(); + if (remaining <= 0) { + break; + } + await delay(Math.min(2 ** (attempt - 1) * 1_000, 5_000, remaining), options.signal); + } + } + throw new Error('runner configuration could not be deleted from SSM'); + } +} + +function isSsmNotFound(error: unknown): boolean { + return errorName(error) === 'ParameterNotFound'; +} diff --git a/lambdas/libs/storage-providers/core/index.ts b/lambdas/libs/storage-providers/core/index.ts index 542b779cfa..3e79ad92a7 100644 --- a/lambdas/libs/storage-providers/core/index.ts +++ b/lambdas/libs/storage-providers/core/index.ts @@ -27,6 +27,16 @@ export interface GitHubAppCredentialsStore { get(): Promise; } +export interface RunnerConfigConsumeOptions { + /** Absolute Unix time in milliseconds after which the operation must stop. */ + deadlineMs: number; + signal: AbortSignal; +} + +export interface RunnerConfigConsumer { + consume(runnerId: string, options: RunnerConfigConsumeOptions): Promise; +} + export interface RunnerGroupCacheRecord { runnerGroupName: string; runnerGroupId: number; diff --git a/lambdas/libs/storage-providers/index.ts b/lambdas/libs/storage-providers/index.ts index 2045ddbbde..36ceec3977 100644 --- a/lambdas/libs/storage-providers/index.ts +++ b/lambdas/libs/storage-providers/index.ts @@ -1,14 +1,17 @@ export type { - RunnerConfigMetadata, + GitHubAppCredential, + GitHubAppCredentialsStore, + RunnerConfigConsumer, + RunnerConfigConsumeOptions, RunnerConfigHousekeeper, + RunnerConfigMetadata, RunnerConfigRecord, RunnerConfigStore, RunnerGroupCacheRecord, RunnerGroupCacheStore, - GitHubAppCredential, - GitHubAppCredentialsStore, } from './core'; -export { createRunnerConfigStore } from './runner-config'; +export { createGitHubAppCredentialsStore } from './github-app-credentials'; export { createRunnerConfigHousekeeper } from './runner-config-housekeeper'; +export { createRunnerConfigStore } from './runner-config'; export { createRunnerGroupCacheStore } from './runner-group-cache'; -export { createGitHubAppCredentialsStore } from './github-app-credentials'; +export { createRunnerConfigConsumer, type RunnerConfigConsumerConfig } from './runner-config-consumer'; diff --git a/lambdas/libs/storage-providers/package.json b/lambdas/libs/storage-providers/package.json index 0ba4f8715a..330d60a3f6 100644 --- a/lambdas/libs/storage-providers/package.json +++ b/lambdas/libs/storage-providers/package.json @@ -4,7 +4,8 @@ "main": "index.ts", "exports": { ".": "./index.ts", - "./aws/ssm/runner-config-housekeeper": "./aws/ssm/runner-config-housekeeper.ts" + "./aws/ssm/runner-config-housekeeper": "./aws/ssm/runner-config-housekeeper.ts", + "./runner-config-consumer": "./runner-config-consumer.ts" }, "type": "module", "license": "MIT", diff --git a/lambdas/libs/storage-providers/runner-config-consumer-common.ts b/lambdas/libs/storage-providers/runner-config-consumer-common.ts new file mode 100644 index 0000000000..114bf38ad4 --- /dev/null +++ b/lambdas/libs/storage-providers/runner-config-consumer-common.ts @@ -0,0 +1,220 @@ +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-subpath.test.ts b/lambdas/libs/storage-providers/runner-config-consumer-subpath.test.ts new file mode 100644 index 0000000000..1ed795a5d1 --- /dev/null +++ b/lambdas/libs/storage-providers/runner-config-consumer-subpath.test.ts @@ -0,0 +1,31 @@ +import { + createRunnerConfigConsumerFromEnvironment, + exportRunnerConfigStorageEnvironment, + loadRunnerConfigStorageContextFromEnvironment, + parseRunnerConfigStorageContext, + type RunnerConfigConsumeOptions, + type RunnerConfigConsumer, + type RunnerConfigStorageContext, + type RunnerConfigStorageEnvironment, +} from '@aws-github-runner/storage-providers/runner-config-consumer'; +import { describe, expect, it } from 'vitest'; + +describe('runner config consumer package subpath', () => { + it('exposes the portable environment round-trip and consumer contract', () => { + const context: RunnerConfigStorageContext = { + RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', + SSM_TOKEN_PATH: '/runner/tokens', + }; + const options: RunnerConfigConsumeOptions = { + deadlineMs: Date.now() + 1_000, + signal: new AbortController().signal, + }; + const exported: RunnerConfigStorageEnvironment = exportRunnerConfigStorageEnvironment(context, {}); + const consumerFactory: () => RunnerConfigConsumer = createRunnerConfigConsumerFromEnvironment; + + expect(parseRunnerConfigStorageContext(context)).toEqual(context); + expect(loadRunnerConfigStorageContextFromEnvironment(exported)).toEqual(context); + expect(consumerFactory).toBeTypeOf('function'); + expect(options.signal.aborted).toBe(false); + }); +}); diff --git a/lambdas/libs/storage-providers/runner-config-consumer.test.ts b/lambdas/libs/storage-providers/runner-config-consumer.test.ts new file mode 100644 index 0000000000..fefcfb61bd --- /dev/null +++ b/lambdas/libs/storage-providers/runner-config-consumer.test.ts @@ -0,0 +1,186 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { AwsSdkSsmRunnerConfigApi, type AwsSsmRunnerConfigApi } from './aws/ssm/runner-config-consumer'; +import { + createRunnerConfigConsumerFromEnvironment, + exportRunnerConfigStorageEnvironment, + loadRunnerConfigConsumerConfigFromEnvironment, + loadRunnerConfigStorageContextFromEnvironment, + parseRunnerConfigStorageContext, + runnerConfigStorageEnvironment, +} from './runner-config-consumer'; + +const ssmContext = { + RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', + SSM_TOKEN_PATH: '/runner/tokens', +} as const; +describe('runner config storage context', () => { + it('parses and freezes an exact SSM environment map while canonicalizing one trailing slash', () => { + const context = parseRunnerConfigStorageContext({ ...ssmContext, SSM_TOKEN_PATH: '/runner/tokens/' }); + + expect(context).toEqual(ssmContext); + expect(Object.isFrozen(context)).toBe(true); + expect(runnerConfigStorageEnvironment(context)).toEqual(ssmContext); + }); + + it.each([ + null, + [], + 'aws_ssm', + { RUNNER_CONFIG_STORAGE_PROVIDER: 'AWS_SSM', SSM_TOKEN_PATH: '/runner/tokens' }, + { RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm' }, + { ...ssmContext, unexpected: true }, + { ...ssmContext, AWS_ACCESS_KEY_ID: 'payload-must-not-export-credentials' }, + { ...ssmContext, RUNNER_CONFIG_TIMEOUT_SECONDS: '60' }, + { ...ssmContext, RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME: 'runner-state' }, + { ...ssmContext, SSM_TOKEN_PATH: '/runner//tokens' }, + { ...ssmContext, SSM_TOKEN_PATH: '/runner/tokens//' }, + { ...ssmContext, SSM_TOKEN_PATH: '/awsParameters/tokens' }, + { ...ssmContext, SSM_TOKEN_PATH: '/ssm-private/tokens' }, + { ...ssmContext, SSM_TOKEN_PATH: '/runner/../tokens' }, + { RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_dynamodb', SSM_TOKEN_PATH: '/runner/tokens' }, + { provider: 'aws_dynamodb', tableName: 'runner-state' }, + ])('rejects a non-allowlisted or incomplete storage context %#', (value) => { + expect(() => parseRunnerConfigStorageContext(value)).toThrow(); + }); + + it('rejects symbol fields that would be hidden by JSON-style key enumeration', () => { + const context = { ...ssmContext }; + Object.defineProperty(context, Symbol('unexpected'), { value: true }); + + expect(() => parseRunnerConfigStorageContext(context)).toThrow('storage context is invalid'); + }); + + it.each([ + [ + { + ...ssmContext, + AWS_ACCESS_KEY_ID: 'producer-only', + RUNNER_CONFIG_TIMEOUT_SECONDS: '30', + UNRELATED: 'kept-out', + }, + ssmContext, + ], + ])('selects only the chosen provider locator from a broader producer environment %#', (environment, expected) => { + expect(loadRunnerConfigStorageContextFromEnvironment(environment)).toEqual(expected); + }); + + it('defaults a legacy producer environment with only SSM_TOKEN_PATH to aws_ssm', () => { + expect(loadRunnerConfigStorageContextFromEnvironment({ SSM_TOKEN_PATH: '/runner/tokens' })).toEqual(ssmContext); + }); + + it('round-trips each producer environment through payload context and hook environment export', () => { + for (const producerEnvironment of [ssmContext]) { + const payloadContext = loadRunnerConfigStorageContextFromEnvironment(producerEnvironment); + const hookEnvironment: Record = { + SSM_TOKEN_PATH: '/stale/path', + UNRELATED: 'preserved', + }; + + const exported = exportRunnerConfigStorageEnvironment(payloadContext, hookEnvironment); + expect(exported).toEqual(payloadContext); + expect(loadRunnerConfigStorageContextFromEnvironment(exported)).toEqual(payloadContext); + expect(hookEnvironment.UNRELATED).toBe('preserved'); + expect(hookEnvironment.SSM_TOKEN_PATH).toBe('/stale/path'); + } + }); + + it('does not mutate a target when context validation fails', () => { + const target = { ...ssmContext } as Record; + + expect(() => + exportRunnerConfigStorageEnvironment({ ...ssmContext, unexpected: 'forbidden' } as never, target), + ).toThrow(); + expect(target).toEqual(ssmContext); + }); +}); + +describe('runner config consumer environment factory', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('creates an injected SSM consumer from exported environment', async () => { + const api: AwsSsmRunnerConfigApi = { + getParameter: vi.fn().mockResolvedValue('encoded-jit'), + deleteParameter: vi.fn().mockResolvedValue(undefined), + }; + const consumer = createRunnerConfigConsumerFromEnvironment(ssmContext, { + awsSsmApi: api, + callTimeoutMs: 100, + configTimeoutMs: 100, + pollIntervalMs: 1, + }); + + await expect( + consumer.consume('microvm-123', { + deadlineMs: Date.now() + 1_000, + signal: new AbortController().signal, + }), + ).resolves.toBe('encoded-jit'); + expect(api.getParameter).toHaveBeenCalledWith('/runner/tokens/microvm-123', expect.any(AbortSignal)); + expect(api.deleteParameter).toHaveBeenCalledWith('/runner/tokens/microvm-123', expect.any(AbortSignal)); + }); + + it('loads timing defaults and overrides from the supplied factory environment', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + vi.spyOn(AwsSdkSsmRunnerConfigApi.prototype, 'getParameter').mockResolvedValue(undefined); + const consumer = createRunnerConfigConsumerFromEnvironment( + { + ...ssmContext, + RUNNER_CONFIG_TIMEOUT_SECONDS: '1', + RUNNER_CONFIG_POLL_SECONDS: '1', + }, + undefined, + ); + const startedAt = Date.now(); + const pending = consumer.consume('microvm-123', { + deadlineMs: startedAt + 10_000, + signal: new AbortController().signal, + }); + const rejection = expect(pending).rejects.toThrow( + 'runner configuration did not become available before the deadline', + ); + + await vi.runAllTimersAsync(); + + await rejection; + expect(Date.now() - startedAt).toBe(1_000); + }); + + it('loads source-compatible timing defaults', () => { + expect(loadRunnerConfigConsumerConfigFromEnvironment({})).toEqual({ + callTimeoutMs: 5_000, + configTimeoutMs: 20_000, + deleteAttempts: 3, + pollIntervalMs: 2_000, + }); + }); + + it('loads bounded timing overrides from an environment', () => { + expect( + loadRunnerConfigConsumerConfigFromEnvironment({ + AWS_SDK_CALL_TIMEOUT_SECONDS: '7', + RUNNER_CONFIG_TIMEOUT_SECONDS: '31', + RUNNER_CONFIG_DELETE_ATTEMPTS: '4', + RUNNER_CONFIG_POLL_SECONDS: '3', + }), + ).toEqual({ + callTimeoutMs: 7_000, + configTimeoutMs: 31_000, + deleteAttempts: 4, + pollIntervalMs: 3_000, + }); + }); + + it.each([ + ['AWS_SDK_CALL_TIMEOUT_SECONDS', '0', 'callTimeoutMs', 5_000], + ['RUNNER_CONFIG_TIMEOUT_SECONDS', '61', 'configTimeoutMs', 20_000], + ['RUNNER_CONFIG_DELETE_ATTEMPTS', '11', 'deleteAttempts', 3], + ['RUNNER_CONFIG_POLL_SECONDS', 'not-a-number', 'pollIntervalMs', 2_000], + ])('falls back for invalid %s=%j', (name, value, property, expected) => { + expect(loadRunnerConfigConsumerConfigFromEnvironment({ [name]: value })).toHaveProperty(property, expected); + }); +}); diff --git a/lambdas/libs/storage-providers/runner-config-consumer.ts b/lambdas/libs/storage-providers/runner-config-consumer.ts new file mode 100644 index 0000000000..f0db7ee5bb --- /dev/null +++ b/lambdas/libs/storage-providers/runner-config-consumer.ts @@ -0,0 +1,131 @@ +import { createAwsSsmRunnerConfigConsumer, type AwsSsmRunnerConfigApi } from './aws/ssm/runner-config-consumer'; +import type { RunnerConfigConsumer } from './core'; +import { canonicalSsmTokenPath, type RunnerConfigPollingOptions } from './runner-config-consumer-common'; + +export type { RunnerConfigConsumerConfig }; +export type { RunnerConfigConsumeOptions, RunnerConfigConsumer } from './core'; +export type { AwsSsmRunnerConfigApi } from './aws/ssm/runner-config-consumer'; + +type Environment = Readonly>; +type MutableEnvironment = Record; + +export interface RunnerConfigConsumerConfig extends RunnerConfigPollingOptions { + awsSsmApi?: AwsSsmRunnerConfigApi; + deleteAttempts?: number; +} + +export interface RunnerConfigStorageContext { + RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm'; + SSM_TOKEN_PATH: string; +} + +export type RunnerConfigStorageEnvironment = RunnerConfigStorageContext; + +/** Creates the consumer from one immutable snapshot of the producer environment. */ +export function createRunnerConfigConsumer( + environment: Environment = process.env, + config?: RunnerConfigConsumerConfig, +): RunnerConfigConsumer { + const tokenPath = canonicalSsmTokenPath(requireTokenPath(environment)); + const resolvedConfig = config ?? loadRunnerConfigConsumerConfigFromEnvironment(environment); + return createAwsSsmRunnerConfigConsumer( + { SSM_TOKEN_PATH: tokenPath }, + { + api: resolvedConfig.awsSsmApi, + callTimeoutMs: resolvedConfig.callTimeoutMs, + configTimeoutMs: resolvedConfig.configTimeoutMs, + deleteAttempts: resolvedConfig.deleteAttempts, + pollIntervalMs: resolvedConfig.pollIntervalMs, + }, + ); +} + +/** @deprecated Use createRunnerConfigConsumer. */ +export const createRunnerConfigConsumerFromEnvironment = createRunnerConfigConsumer; + +export function parseRunnerConfigStorageContext(value: unknown): RunnerConfigStorageContext { + if (!isPlainObject(value) || value.RUNNER_CONFIG_STORAGE_PROVIDER !== 'aws_ssm') { + 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'); + } + return Object.freeze({ + RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', + SSM_TOKEN_PATH: canonicalSsmTokenPath(value.SSM_TOKEN_PATH), + }); +} + +export function loadRunnerConfigStorageContextFromEnvironment( + environment: Environment = process.env, +): RunnerConfigStorageContext { + return parseRunnerConfigStorageContext({ + RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', + SSM_TOKEN_PATH: environment.SSM_TOKEN_PATH, + }); +} + +export function runnerConfigStorageEnvironment(context: RunnerConfigStorageContext): RunnerConfigStorageEnvironment { + return parseRunnerConfigStorageContext(context); +} + +/** Returns an allowlisted environment object without mutating process.env or a caller-owned target. */ +export function exportRunnerConfigStorageEnvironment( + context: RunnerConfigStorageContext, + ..._ignoredTarget: [MutableEnvironment?] +): RunnerConfigStorageEnvironment { + return runnerConfigStorageEnvironment(context); +} + +export function loadRunnerConfigConsumerConfigFromEnvironment( + environment: Environment = process.env, +): RunnerConfigConsumerConfig { + return { + callTimeoutMs: secondsEnvironmentValue(environment, 'AWS_SDK_CALL_TIMEOUT_SECONDS', 5) * 1_000, + configTimeoutMs: secondsEnvironmentValue(environment, 'RUNNER_CONFIG_TIMEOUT_SECONDS', 20) * 1_000, + deleteAttempts: positiveIntegerEnvironmentValue(environment, 'RUNNER_CONFIG_DELETE_ATTEMPTS', 3, 10), + pollIntervalMs: secondsEnvironmentValue(environment, 'RUNNER_CONFIG_POLL_SECONDS', 2) * 1_000, + }; +} + +function requireTokenPath(environment: Environment): string { + const tokenPath = environment.SSM_TOKEN_PATH; + if (!tokenPath || tokenPath.trim() === '') { + throw new Error('Environment variable SSM_TOKEN_PATH is not set'); + } + return tokenPath; +} + +function secondsEnvironmentValue(environment: Environment, name: string, fallback: number): number { + return positiveIntegerEnvironmentValue(environment, name, fallback, 60); +} + +function positiveIntegerEnvironmentValue( + environment: Environment, + name: string, + fallback: number, + maximum: number, +): number { + const value = environment[name]; + if (value === undefined || !/^\d+$/.test(value)) { + return fallback; + } + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 && parsed <= maximum ? parsed : fallback; +} + +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value) as unknown; + return prototype === Object.prototype || prototype === null; +} + +function hasExactKeys(value: object, expected: readonly string[]): boolean { + const keys = Reflect.ownKeys(value); + return keys.length === expected.length && keys.every((key) => typeof key === 'string' && expected.includes(key)); +} diff --git a/lambdas/libs/storage-providers/vitest.config.ts b/lambdas/libs/storage-providers/vitest.config.ts index a5812ad13e..306d14e01b 100644 --- a/lambdas/libs/storage-providers/vitest.config.ts +++ b/lambdas/libs/storage-providers/vitest.config.ts @@ -7,7 +7,17 @@ export default mergeConfig(defaultConfig, { test: { setupFiles: [resolve(__dirname, '../../aws-vitest-setup.ts')], coverage: { - include: ['index.ts', 'runner-config.ts', 'core/**/*.ts', 'aws/**/*.ts'], + 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', + 'core/**/*.ts', + 'aws/**/*.ts', + ], exclude: ['**/*.test.ts', '**/*.d.ts'], }, }, From 5254589aad06973470708aae16202176b2d529f7 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 4 Sep 2026 14:35:42 +0200 Subject: [PATCH 2/4] fix(storage): simplify consumer environment export --- .../runner-config-consumer-subpath.test.ts | 2 +- .../runner-config-consumer.test.ts | 16 +++------------- .../storage-providers/runner-config-consumer.ts | 6 +----- 3 files changed, 5 insertions(+), 19 deletions(-) diff --git a/lambdas/libs/storage-providers/runner-config-consumer-subpath.test.ts b/lambdas/libs/storage-providers/runner-config-consumer-subpath.test.ts index 1ed795a5d1..885893fa79 100644 --- a/lambdas/libs/storage-providers/runner-config-consumer-subpath.test.ts +++ b/lambdas/libs/storage-providers/runner-config-consumer-subpath.test.ts @@ -20,7 +20,7 @@ describe('runner config consumer package subpath', () => { deadlineMs: Date.now() + 1_000, signal: new AbortController().signal, }; - const exported: RunnerConfigStorageEnvironment = exportRunnerConfigStorageEnvironment(context, {}); + const exported: RunnerConfigStorageEnvironment = exportRunnerConfigStorageEnvironment(context); const consumerFactory: () => RunnerConfigConsumer = createRunnerConfigConsumerFromEnvironment; expect(parseRunnerConfigStorageContext(context)).toEqual(context); diff --git a/lambdas/libs/storage-providers/runner-config-consumer.test.ts b/lambdas/libs/storage-providers/runner-config-consumer.test.ts index fefcfb61bd..d7e5d6d6a0 100644 --- a/lambdas/libs/storage-providers/runner-config-consumer.test.ts +++ b/lambdas/libs/storage-providers/runner-config-consumer.test.ts @@ -72,26 +72,16 @@ describe('runner config storage context', () => { it('round-trips each producer environment through payload context and hook environment export', () => { for (const producerEnvironment of [ssmContext]) { const payloadContext = loadRunnerConfigStorageContextFromEnvironment(producerEnvironment); - const hookEnvironment: Record = { - SSM_TOKEN_PATH: '/stale/path', - UNRELATED: 'preserved', - }; - - const exported = exportRunnerConfigStorageEnvironment(payloadContext, hookEnvironment); + const exported = exportRunnerConfigStorageEnvironment(payloadContext); expect(exported).toEqual(payloadContext); expect(loadRunnerConfigStorageContextFromEnvironment(exported)).toEqual(payloadContext); - expect(hookEnvironment.UNRELATED).toBe('preserved'); - expect(hookEnvironment.SSM_TOKEN_PATH).toBe('/stale/path'); } }); - it('does not mutate a target when context validation fails', () => { - const target = { ...ssmContext } as Record; - + it('rejects unexpected context keys', () => { expect(() => - exportRunnerConfigStorageEnvironment({ ...ssmContext, unexpected: 'forbidden' } as never, target), + exportRunnerConfigStorageEnvironment({ ...ssmContext, unexpected: 'forbidden' } as never), ).toThrow(); - expect(target).toEqual(ssmContext); }); }); diff --git a/lambdas/libs/storage-providers/runner-config-consumer.ts b/lambdas/libs/storage-providers/runner-config-consumer.ts index f0db7ee5bb..c4daafc511 100644 --- a/lambdas/libs/storage-providers/runner-config-consumer.ts +++ b/lambdas/libs/storage-providers/runner-config-consumer.ts @@ -7,7 +7,6 @@ export type { RunnerConfigConsumeOptions, RunnerConfigConsumer } from './core'; export type { AwsSsmRunnerConfigApi } from './aws/ssm/runner-config-consumer'; type Environment = Readonly>; -type MutableEnvironment = Record; export interface RunnerConfigConsumerConfig extends RunnerConfigPollingOptions { awsSsmApi?: AwsSsmRunnerConfigApi; @@ -73,10 +72,7 @@ export function runnerConfigStorageEnvironment(context: RunnerConfigStorageConte } /** Returns an allowlisted environment object without mutating process.env or a caller-owned target. */ -export function exportRunnerConfigStorageEnvironment( - context: RunnerConfigStorageContext, - ..._ignoredTarget: [MutableEnvironment?] -): RunnerConfigStorageEnvironment { +export function exportRunnerConfigStorageEnvironment(context: RunnerConfigStorageContext): RunnerConfigStorageEnvironment { return runnerConfigStorageEnvironment(context); } From af716e07a5637faa16c34a58c03f235decd6e425 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 4 Sep 2026 14:44:26 +0200 Subject: [PATCH 3/4] fix(storage): format runner config consumer --- lambdas/libs/storage-providers/runner-config-consumer.test.ts | 4 +--- lambdas/libs/storage-providers/runner-config-consumer.ts | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lambdas/libs/storage-providers/runner-config-consumer.test.ts b/lambdas/libs/storage-providers/runner-config-consumer.test.ts index d7e5d6d6a0..f51f067589 100644 --- a/lambdas/libs/storage-providers/runner-config-consumer.test.ts +++ b/lambdas/libs/storage-providers/runner-config-consumer.test.ts @@ -79,9 +79,7 @@ describe('runner config storage context', () => { }); it('rejects unexpected context keys', () => { - expect(() => - exportRunnerConfigStorageEnvironment({ ...ssmContext, unexpected: 'forbidden' } as never), - ).toThrow(); + expect(() => exportRunnerConfigStorageEnvironment({ ...ssmContext, unexpected: 'forbidden' } as never)).toThrow(); }); }); diff --git a/lambdas/libs/storage-providers/runner-config-consumer.ts b/lambdas/libs/storage-providers/runner-config-consumer.ts index c4daafc511..6b516a609d 100644 --- a/lambdas/libs/storage-providers/runner-config-consumer.ts +++ b/lambdas/libs/storage-providers/runner-config-consumer.ts @@ -72,7 +72,9 @@ export function runnerConfigStorageEnvironment(context: RunnerConfigStorageConte } /** Returns an allowlisted environment object without mutating process.env or a caller-owned target. */ -export function exportRunnerConfigStorageEnvironment(context: RunnerConfigStorageContext): RunnerConfigStorageEnvironment { +export function exportRunnerConfigStorageEnvironment( + context: RunnerConfigStorageContext, +): RunnerConfigStorageEnvironment { return runnerConfigStorageEnvironment(context); } From b4d0535ec21591d87f5fb42dccd4023424e2f683 Mon Sep 17 00:00:00 2001 From: edersonbrilhante Date: Fri, 4 Sep 2026 14:53:31 +0200 Subject: [PATCH 4/4] fix(storage): remove duplicate consumer config export --- lambdas/libs/storage-providers/runner-config-consumer.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lambdas/libs/storage-providers/runner-config-consumer.ts b/lambdas/libs/storage-providers/runner-config-consumer.ts index 6b516a609d..1dc14ae7ba 100644 --- a/lambdas/libs/storage-providers/runner-config-consumer.ts +++ b/lambdas/libs/storage-providers/runner-config-consumer.ts @@ -2,7 +2,6 @@ import { createAwsSsmRunnerConfigConsumer, type AwsSsmRunnerConfigApi } from './ import type { RunnerConfigConsumer } from './core'; import { canonicalSsmTokenPath, type RunnerConfigPollingOptions } from './runner-config-consumer-common'; -export type { RunnerConfigConsumerConfig }; export type { RunnerConfigConsumeOptions, RunnerConfigConsumer } from './core'; export type { AwsSsmRunnerConfigApi } from './aws/ssm/runner-config-consumer';