Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@ vi.mock('@aws-github-runner/aws-ssm-util', () => ({
}));

const getParametersMock = vi.mocked(getParameters);
const loggerMock = vi.hoisted(() => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}));

vi.mock('@aws-github-runner/aws-powertools-util', () => ({
createChildLogger: vi.fn(() => ({
...loggerMock,
appendPersistentKeys: vi.fn(),
})),
}));

describe('aws_ssm GitHub App credentials store', () => {
beforeEach(() => {
Expand Down Expand Up @@ -62,4 +75,31 @@ describe('aws_ssm GitHub App credentials store', () => {
process.env.PARAMETER_GITHUB_APP_ID_NAME = 'id-0:id-1';
expect(() => createAwsSsmGitHubAppCredentialsStore()).toThrow('parameter count mismatch');
});

it('logs safe context when a credential parameter is missing', async () => {
getParametersMock.mockResolvedValue(new Map([['app-key', Buffer.from('private-key').toString('base64')]]));

await expect(createAwsSsmGitHubAppCredentialsStore().get()).rejects.toThrow('Parameter app-id not found');

expect(loggerMock.error).toHaveBeenCalledWith('GitHub App credential parameter is missing', {
credentialField: 'appId',
appIndex: 0,
parameterName: 'app-id',
});
expect(JSON.stringify(loggerMock.error.mock.calls)).not.toContain('private-key');
});

it('logs only error names when the provider lookup fails', async () => {
const error = Object.assign(new Error('private-key-secret'), { name: 'InternalServerException' });
getParametersMock.mockRejectedValue(error);

await expect(createAwsSsmGitHubAppCredentialsStore().get()).rejects.toBe(error);

expect(loggerMock.error).toHaveBeenCalledWith('Failed to read GitHub App credential parameters', {
parameterCount: 2,
appCount: 1,
errorNames: ['InternalServerException'],
});
expect(JSON.stringify(loggerMock.error.mock.calls)).not.toContain('private-key-secret');
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { getParameters } from '@aws-github-runner/aws-ssm-util';

import type { GitHubAppCredential, GitHubAppCredentialsStore } from '../../core';
import { createAwsSsmStorageLogger, getErrorNames } from './logger';

const logger = createAwsSsmStorageLogger('github-app-credentials-store');

interface AwsSsmGitHubAppCredentialsEnvironment {
PARAMETER_GITHUB_APP_ID_NAME?: string;
Expand Down Expand Up @@ -33,19 +36,47 @@ class AwsSsmGitHubAppCredentialsStore implements GitHubAppCredentialsStore {
) {}

async get(): Promise<GitHubAppCredential[]> {
const parameters = await getParameters([
const parameterNames = [
...this.idParameters,
...this.keyParameters,
...this.installationIdParameters.filter(Boolean),
]);
return this.idParameters.map((idParameter, index) => {
];
logger.debug('Reading GitHub App credential parameters', {
parameterCount: parameterNames.length,
appCount: this.idParameters.length,
});

let parameters: Map<string, string>;
try {
parameters = await getParameters(parameterNames);
} catch (error) {
logger.error('Failed to read GitHub App credential parameters', {
parameterCount: parameterNames.length,
appCount: this.idParameters.length,
errorNames: getErrorNames(error),
});
throw error;
}

const credentials = this.idParameters.map((idParameter, index) => {
const appIdValue = parameters.get(idParameter);
if (!appIdValue) {
logger.error('GitHub App credential parameter is missing', {
credentialField: 'appId',
appIndex: index,
parameterName: idParameter,
});
throw new Error(`Parameter ${idParameter} not found`);
}
const privateKeyBase64 = parameters.get(this.keyParameters[index]);
const keyParameter = this.keyParameters[index];
const privateKeyBase64 = parameters.get(keyParameter);
if (!privateKeyBase64) {
throw new Error(`Parameter ${this.keyParameters[index]} not found`);
logger.error('GitHub App credential parameter is missing', {
credentialField: 'privateKey',
appIndex: index,
parameterName: keyParameter,
});
throw new Error(`Parameter ${keyParameter} not found`);
}
const installationIdParameter = this.installationIdParameters[index];
const installationIdValue = installationIdParameter ? parameters.get(installationIdParameter) : undefined;
Expand All @@ -55,6 +86,12 @@ class AwsSsmGitHubAppCredentialsStore implements GitHubAppCredentialsStore {
installationId: installationIdValue ? Number.parseInt(installationIdValue, 10) : undefined,
};
});

logger.debug('Loaded GitHub App credential parameters', {
appCount: credentials.length,
installationIdCount: credentials.filter(({ installationId }) => installationId !== undefined).length,
});
return credentials;
}
}

Expand Down
31 changes: 31 additions & 0 deletions lambdas/libs/storage-providers/aws/ssm/logger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it, vi } from 'vitest';

import { runnerConfigStorageProvider } from '../../provider';
import { createAwsSsmStorageLogger, getErrorNames } from './logger';

const loggerMock = vi.hoisted(() => ({
appendPersistentKeys: vi.fn(),
}));
const createChildLoggerMock = vi.hoisted(() => vi.fn(() => loggerMock));

vi.mock('@aws-github-runner/aws-powertools-util', () => ({
createChildLogger: createChildLoggerMock,
}));

describe('AWS SSM storage logger', () => {
it('adds the canonical storage provider while preserving the adapter module', () => {
expect(createAwsSsmStorageLogger('runner-config-store')).toBe(loggerMock);
expect(createChildLoggerMock).toHaveBeenCalledWith('runner-config-store');
expect(loggerMock.appendPersistentKeys).toHaveBeenCalledWith({
storageProvider: runnerConfigStorageProvider.awsSsm,
});
});

it('returns bounded error names from a cause chain', () => {
const cause = Object.assign(new Error('missing'), { name: 'ParameterNotFound' });
const error = Object.assign(new Error('wrapped'), { name: 'GetParameterError', cause });
Object.assign(cause, { cause: error });

expect(getErrorNames(error)).toEqual(['GetParameterError', 'ParameterNotFound']);
});
});
27 changes: 27 additions & 0 deletions lambdas/libs/storage-providers/aws/ssm/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createChildLogger } from '@aws-github-runner/aws-powertools-util';

import { runnerConfigStorageProvider } from '../../provider';

export function createAwsSsmStorageLogger(module: string) {
const logger = createChildLogger(module);
logger.appendPersistentKeys({
storageProvider: runnerConfigStorageProvider.awsSsm,
});
return logger;
}

export function getErrorNames(error: unknown): string[] {
const names: string[] = [];
const seen = new Set<object>();
let current: unknown = error;

while (current !== null && typeof current === 'object' && !seen.has(current)) {
seen.add(current);
if ('name' in current && typeof current.name === 'string') {
names.push(current.name);
}
current = 'cause' in current ? current.cause : undefined;
}

return names;
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
import { DeleteParameterCommand, GetParameterCommand, type SSMClient } from '@aws-sdk/client-ssm';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import {
AwsSdkSsmRunnerConfigApi,
createAwsSsmRunnerConfigConsumer,
type AwsSsmRunnerConfigApi,
} from './runner-config-consumer';

const loggerMock = vi.hoisted(() => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}));

vi.mock('@aws-github-runner/aws-powertools-util', () => ({
createChildLogger: vi.fn(() => ({
...loggerMock,
appendPersistentKeys: vi.fn(),
})),
}));

function namedError(name: string, message = 'provider detail'): Error {
const error = new Error(message);
error.name = name;
Expand Down Expand Up @@ -38,6 +52,10 @@ describe('AWS SDK SSM runner config API', () => {
});

describe('SSM runner config consumer', () => {
beforeEach(() => {
vi.clearAllMocks();
});

afterEach(() => {
vi.useRealTimers();
});
Expand All @@ -49,7 +67,7 @@ describe('SSM runner config consumer', () => {
.mockResolvedValueOnce('encoded-jit');
const deleteParameter = vi.fn<AwsSsmRunnerConfigApi['deleteParameter']>().mockResolvedValue(undefined);
const consumer = createAwsSsmRunnerConfigConsumer(
{ RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', SSM_TOKEN_PATH: '/runner/tokens' },
{ SSM_TOKEN_PATH: '/runner/tokens' },
{
api: { getParameter, deleteParameter },
callTimeoutMs: 100,
Expand Down Expand Up @@ -80,7 +98,7 @@ describe('SSM runner config consumer', () => {
.mockResolvedValueOnce(undefined),
};
const consumer = createAwsSsmRunnerConfigConsumer(
{ RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', SSM_TOKEN_PATH: '/runner/tokens' },
{ SSM_TOKEN_PATH: '/runner/tokens' },
{ api, callTimeoutMs: 100, configTimeoutMs: 2_000, deleteAttempts: 2, pollIntervalMs: 1 },
);

Expand All @@ -95,41 +113,61 @@ describe('SSM runner config consumer', () => {
});

it('fails closed when another reader deletes the SSM parameter first', async () => {
const deleteError = namedError('ParameterNotFound');
const api: AwsSsmRunnerConfigApi = {
getParameter: vi.fn().mockResolvedValue('encoded-jit'),
deleteParameter: vi.fn().mockRejectedValue(namedError('ParameterNotFound')),
deleteParameter: vi.fn().mockRejectedValue(deleteError),
};
const consumer = createAwsSsmRunnerConfigConsumer(
{ RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', SSM_TOKEN_PATH: '/runner/tokens' },
{ 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');
const pending = consumer.consume('runner-123', {
deadlineMs: Date.now() + 1_000,
signal: new AbortController().signal,
});

await expect(pending).rejects.toMatchObject({
message: 'runner configuration could not be deleted from SSM',
cause: deleteError,
});
expect(api.deleteParameter).toHaveBeenCalledOnce();
expect(loggerMock.error).toHaveBeenCalledWith('Failed to delete consumed runner configuration', {
runnerId: 'runner-123',
parameterName: '/runner/tokens/runner-123',
deleteAttempts: 1,
errorNames: ['ParameterNotFound'],
});
});

it('sanitizes non-retryable provider failures', async () => {
const providerError = namedError('AccessDeniedException', 'encoded-jit-secret');
const api: AwsSsmRunnerConfigApi = {
getParameter: vi.fn().mockRejectedValue(namedError('AccessDeniedException', 'encoded-jit-secret')),
getParameter: vi.fn().mockRejectedValue(providerError),
deleteParameter: vi.fn(),
};
const consumer = createAwsSsmRunnerConfigConsumer(
{ RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', SSM_TOKEN_PATH: '/runner/tokens' },
{ 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');
await expect(pending).rejects.toMatchObject({
message: 'failed to read runner configuration from SSM',
cause: providerError,
});
expect(api.deleteParameter).not.toHaveBeenCalled();
expect(loggerMock.error).toHaveBeenCalledWith('Failed to read runner configuration', {
runnerId: 'runner-123',
parameterName: '/runner/tokens/runner-123',
pollAttempt: 1,
errorNames: ['AccessDeniedException'],
});
expect(JSON.stringify(loggerMock.error.mock.calls)).not.toContain('encoded-jit-secret');
});

it('rejects an empty SSM parameter value without attempting deletion', async () => {
Expand All @@ -138,7 +176,7 @@ describe('SSM runner config consumer', () => {
deleteParameter: vi.fn(),
};
const consumer = createAwsSsmRunnerConfigConsumer(
{ RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', SSM_TOKEN_PATH: '/runner/tokens' },
{ SSM_TOKEN_PATH: '/runner/tokens' },
{ api, callTimeoutMs: 100, configTimeoutMs: 100, pollIntervalMs: 1 },
);

Expand All @@ -157,7 +195,7 @@ describe('SSM runner config consumer', () => {
deleteParameter: vi.fn(),
};
const consumer = createAwsSsmRunnerConfigConsumer(
{ RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', SSM_TOKEN_PATH: `/${'x'.repeat(890)}` },
{ SSM_TOKEN_PATH: `/${'x'.repeat(890)}` },
{ api, callTimeoutMs: 100, configTimeoutMs: 100, pollIntervalMs: 1 },
);

Expand All @@ -177,7 +215,7 @@ describe('SSM runner config consumer', () => {
};
const controller = new AbortController();
const consumer = createAwsSsmRunnerConfigConsumer(
{ RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', SSM_TOKEN_PATH: '/runner/tokens' },
{ SSM_TOKEN_PATH: '/runner/tokens' },
{ api, callTimeoutMs: 10_000, configTimeoutMs: 10_000, pollIntervalMs: 1 },
);
const pending = consumer.consume('runner-123', {
Expand All @@ -202,7 +240,7 @@ describe('SSM runner config consumer', () => {
}),
};
const consumer = createAwsSsmRunnerConfigConsumer(
{ RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_ssm', SSM_TOKEN_PATH: '/runner/tokens' },
{ SSM_TOKEN_PATH: '/runner/tokens' },
{ api, callTimeoutMs: 100, configTimeoutMs: 1_000, pollIntervalMs: 99 },
);

Expand Down
Loading