diff --git a/lambdas/libs/storage-providers/aws/dynamodb/client.ts b/lambdas/libs/storage-providers/aws/dynamodb/client.ts new file mode 100644 index 0000000000..86493cc23f --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/client.ts @@ -0,0 +1,21 @@ +import { getTracedAWSV3Client } from '@aws-github-runner/aws-powertools-util'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; + +let memoisedClient: DynamoDBClient | undefined; + +export function getDynamoDbClient(): DynamoDBClient { + memoisedClient ??= getTracedAWSV3Client( + new DynamoDBClient({ + region: process.env.AWS_REGION, + maxAttempts: 10, + // One client serves two tables, so avoid an adaptive rate bucket coupling their throttling behavior. + retryMode: 'standard', + }), + ); + return memoisedClient; +} + +// Test-only reset for cases that need a fresh AWS SDK client. +export function resetDynamoDbClient(): void { + memoisedClient = undefined; +} diff --git a/lambdas/libs/storage-providers/aws/dynamodb/durable-config.ts b/lambdas/libs/storage-providers/aws/dynamodb/durable-config.ts new file mode 100644 index 0000000000..f22fde3269 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/durable-config.ts @@ -0,0 +1,37 @@ +import { GetItemCommand } from '@aws-sdk/client-dynamodb'; + +import { getDynamoDbClient } from './client'; +import { ID_ATTRIBUTE, SCOPE_ATTRIBUTE, VALUE_ATTRIBUTE } from './keys'; + +export async function getDurableConfigValue( + tableName: string, + scope: string, + id: string, + description: string, +): Promise { + const result = await getDynamoDbClient().send( + new GetItemCommand({ + TableName: tableName, + Key: { + [SCOPE_ATTRIBUTE]: { S: scope }, + [ID_ATTRIBUTE]: { S: id }, + }, + ConsistentRead: true, + ProjectionExpression: '#value', + ExpressionAttributeNames: { + '#value': VALUE_ATTRIBUTE, + }, + }), + ); + + if (!result.Item) { + throw new Error(`${description} item '${scope}/${id}' was not found`); + } + + const value = result.Item[VALUE_ATTRIBUTE]?.S; + if (value === undefined) { + throw new Error(`${description} item '${scope}/${id}' does not contain a string value`); + } + + return value; +} diff --git a/lambdas/libs/storage-providers/aws/dynamodb/environment.d.ts b/lambdas/libs/storage-providers/aws/dynamodb/environment.d.ts new file mode 100644 index 0000000000..59be4dbf10 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/environment.d.ts @@ -0,0 +1,13 @@ +export {}; + +declare global { + namespace NodeJS { + interface ProcessEnv { + RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME?: string; + RUNNER_CONFIG_DYNAMODB_ENTRY_ID?: string; + RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME?: string; + RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TTL_SECONDS?: string; + RUNNER_CONFIG_DYNAMODB_TTL_SECONDS?: string; + } + } +} diff --git a/lambdas/libs/storage-providers/aws/dynamodb/environment.ts b/lambdas/libs/storage-providers/aws/dynamodb/environment.ts new file mode 100644 index 0000000000..fc0a394139 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/environment.ts @@ -0,0 +1,29 @@ +type DynamoDbEnvironmentVariable = + | 'RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME' + | 'RUNNER_CONFIG_DYNAMODB_ENTRY_ID' + | 'RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME' + | 'RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TTL_SECONDS' + | 'RUNNER_CONFIG_DYNAMODB_TTL_SECONDS'; + +export function requiredEnvironmentValue(name: DynamoDbEnvironmentVariable): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`Environment variable ${name} is not set`); + } + + return value; +} + +export function positiveIntegerEnvironmentValue(name: DynamoDbEnvironmentVariable): number { + const value = requiredEnvironmentValue(name); + if (!/^[1-9]\d*$/.test(value)) { + throw new Error(`Environment variable ${name} must be a positive integer`); + } + + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + throw new Error(`Environment variable ${name} must be a positive integer`); + } + + return parsed; +} diff --git a/lambdas/libs/storage-providers/aws/dynamodb/github-app-credentials-store.test.ts b/lambdas/libs/storage-providers/aws/dynamodb/github-app-credentials-store.test.ts new file mode 100644 index 0000000000..500ad90749 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/github-app-credentials-store.test.ts @@ -0,0 +1,103 @@ +import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest/vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { resetDynamoDbClient } from './client'; +import { createAwsDynamoDbGitHubAppCredentialsStore } from './github-app-credentials-store'; + +const mockDynamoDbClient = mockClient(DynamoDBClient); +const cleanEnv = process.env; + +describe('aws_dynamodb GitHub App credentials store', () => { + beforeEach(() => { + mockDynamoDbClient.reset(); + resetDynamoDbClient(); + process.env = { ...cleanEnv }; + process.env.AWS_REGION = 'eu-west-1'; + process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME = 'runner-configuration'; + }); + + it('strongly reads and decodes an ordered credential array', async () => { + const value = JSON.stringify([ + { appId: 123, privateKeyBase64: Buffer.from('primary\\nkey').toString('base64') }, + { + appId: 456, + privateKeyBase64: Buffer.from('additional-key').toString('base64'), + installationId: 789, + }, + ]); + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { S: value } } }); + const store = createAwsDynamoDbGitHubAppCredentialsStore(); + + await expect(store.get()).resolves.toEqual([ + { appId: 123, privateKey: 'primary\nkey', installationId: undefined }, + { appId: 456, privateKey: 'additional-key', installationId: 789 }, + ]); + expect(mockDynamoDbClient).toHaveReceivedCommandWith(GetItemCommand, { + TableName: 'runner-configuration', + Key: { + scope: { S: 'global#github-app' }, + id: { S: 'github-app-credentials' }, + }, + ConsistentRead: true, + ProjectionExpression: '#value', + ExpressionAttributeNames: { '#value': 'value' }, + }); + }); + + it.each([ + ['not-json', 'contains invalid JSON'], + ['[]', 'must contain a non-empty array'], + [JSON.stringify([null]), 'credential at index 0 has an invalid stored value'], + [JSON.stringify([{ appId: 0, privateKeyBase64: 'a2V5' }]), 'credential at index 0 has an invalid stored value'], + [ + JSON.stringify([{ appId: 1, privateKeyBase64: 'not-base64' }]), + 'credential at index 0 has an invalid stored value', + ], + [ + JSON.stringify([{ appId: 1, privateKeyBase64: 'a2V5', installationId: 1.5 }]), + 'credential at index 0 has an invalid stored value', + ], + ])('rejects malformed stored credentials without returning their value', async (value, message) => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { S: value } } }); + const store = createAwsDynamoDbGitHubAppCredentialsStore(); + + await expect(store.get()).rejects.toThrow(message); + }); + + it('rejects a missing credentials item', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({}); + + await expect(createAwsDynamoDbGitHubAppCredentialsStore().get()).rejects.toThrow( + "GitHub App credentials item 'global#github-app/github-app-credentials' was not found", + ); + }); + + it('rejects a non-string credentials value', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { L: [] } } }); + + await expect(createAwsDynamoDbGitHubAppCredentialsStore().get()).rejects.toThrow( + "GitHub App credentials item 'global#github-app/github-app-credentials' does not contain a string value", + ); + }); + + it.each([undefined, '', ' '])('requires the durable table name for input %j', (tableName) => { + if (tableName === undefined) { + delete process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME; + } else { + process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME = tableName; + } + + expect(() => createAwsDynamoDbGitHubAppCredentialsStore()).toThrow( + 'Environment variable RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME is not set', + ); + }); + + it('propagates reads errors without exposing stored credentials', async () => { + const error = new Error('access denied'); + mockDynamoDbClient.on(GetItemCommand).rejects(error); + + await expect(createAwsDynamoDbGitHubAppCredentialsStore().get()).rejects.toBe(error); + }); +}); diff --git a/lambdas/libs/storage-providers/aws/dynamodb/github-app-credentials-store.ts b/lambdas/libs/storage-providers/aws/dynamodb/github-app-credentials-store.ts new file mode 100644 index 0000000000..e9b1a56c18 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/github-app-credentials-store.ts @@ -0,0 +1,89 @@ +import type { GitHubAppCredential, GitHubAppCredentialsStore } from '../../core'; +import { getDurableConfigValue } from './durable-config'; +import { requiredEnvironmentValue } from './environment'; +import { GITHUB_APP_CREDENTIALS_ID, GITHUB_APP_SCOPE } from './keys'; + +interface StoredGitHubAppCredential { + appId: number; + privateKeyBase64: string; + installationId?: number; +} + +export function createAwsDynamoDbGitHubAppCredentialsStore(): GitHubAppCredentialsStore { + return new AwsDynamoDbGitHubAppCredentialsStore(requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME')); +} + +class AwsDynamoDbGitHubAppCredentialsStore implements GitHubAppCredentialsStore { + constructor(private readonly tableName: string) {} + + async get(): Promise { + const value = await getDurableConfigValue( + this.tableName, + GITHUB_APP_SCOPE, + GITHUB_APP_CREDENTIALS_ID, + 'GitHub App credentials', + ); + const credentials = parseCredentials(value); + + return credentials.map((credential) => ({ + appId: credential.appId, + privateKey: decodePrivateKey(credential.privateKeyBase64), + installationId: credential.installationId, + })); + } +} + +function parseCredentials(value: string): StoredGitHubAppCredential[] { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error('GitHub App credentials item contains invalid JSON'); + } + + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new Error('GitHub App credentials item must contain a non-empty array'); + } + + return parsed.map((credential, index) => parseCredential(credential, index)); +} + +function parseCredential(value: unknown, index: number): StoredGitHubAppCredential { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw invalidCredential(index); + } + + const credential = value as Record; + if (!isPositiveSafeInteger(credential.appId) || !isValidBase64(credential.privateKeyBase64)) { + throw invalidCredential(index); + } + if (credential.installationId !== undefined && !isPositiveSafeInteger(credential.installationId)) { + throw invalidCredential(index); + } + + return { + appId: credential.appId, + privateKeyBase64: credential.privateKeyBase64, + installationId: credential.installationId, + }; +} + +function isPositiveSafeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; +} + +function isValidBase64(value: unknown): value is string { + if (typeof value !== 'string' || value.length === 0 || value.length % 4 !== 0) { + return false; + } + + return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value); +} + +function decodePrivateKey(privateKeyBase64: string): string { + return Buffer.from(privateKeyBase64, 'base64').toString().replace(/\\n/g, '\n'); +} + +function invalidCredential(index: number): Error { + return new Error(`GitHub App credential at index ${index} has an invalid stored value`); +} diff --git a/lambdas/libs/storage-providers/aws/dynamodb/github-webhook-secret-store.test.ts b/lambdas/libs/storage-providers/aws/dynamodb/github-webhook-secret-store.test.ts new file mode 100644 index 0000000000..c66272c2c7 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/github-webhook-secret-store.test.ts @@ -0,0 +1,74 @@ +import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest/vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { resetDynamoDbClient } from './client'; +import { createAwsDynamoDbGitHubWebhookSecretStore } from './github-webhook-secret-store'; + +const mockDynamoDbClient = mockClient(DynamoDBClient); +const cleanEnv = process.env; + +describe('aws_dynamodb GitHub webhook secret store', () => { + beforeEach(() => { + mockDynamoDbClient.reset(); + resetDynamoDbClient(); + process.env = { ...cleanEnv }; + process.env.AWS_REGION = 'eu-west-1'; + process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME = 'runner-configuration'; + }); + + it('strongly reads the global webhook secret', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { S: 'webhook-secret' } } }); + + await expect(createAwsDynamoDbGitHubWebhookSecretStore().get()).resolves.toBe('webhook-secret'); + expect(mockDynamoDbClient).toHaveReceivedCommandWith(GetItemCommand, { + TableName: 'runner-configuration', + Key: { + scope: { S: 'global#webhook' }, + id: { S: 'github-webhook-secret' }, + }, + ConsistentRead: true, + ProjectionExpression: '#value', + ExpressionAttributeNames: { '#value': 'value' }, + }); + }); + + it('leaves empty-value validation to the webhook config loader', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { S: '' } } }); + + await expect(createAwsDynamoDbGitHubWebhookSecretStore().get()).resolves.toBe(''); + }); + + it('rejects a missing secret item without logging or returning a value', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({}); + + await expect(createAwsDynamoDbGitHubWebhookSecretStore().get()).rejects.toThrow( + "GitHub webhook secret item 'global#webhook/github-webhook-secret' was not found", + ); + }); + + it('rejects a non-string secret item', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { B: new Uint8Array() } } }); + + await expect(createAwsDynamoDbGitHubWebhookSecretStore().get()).rejects.toThrow( + "GitHub webhook secret item 'global#webhook/github-webhook-secret' does not contain a string value", + ); + }); + + it('requires the durable table name before reading', () => { + delete process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME; + + expect(() => createAwsDynamoDbGitHubWebhookSecretStore()).toThrow( + 'Environment variable RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME is not set', + ); + expect(mockDynamoDbClient.calls()).toHaveLength(0); + }); + + it('propagates read errors without handling the secret value', async () => { + const error = new Error('access denied'); + mockDynamoDbClient.on(GetItemCommand).rejects(error); + + await expect(createAwsDynamoDbGitHubWebhookSecretStore().get()).rejects.toBe(error); + }); +}); diff --git a/lambdas/libs/storage-providers/aws/dynamodb/github-webhook-secret-store.ts b/lambdas/libs/storage-providers/aws/dynamodb/github-webhook-secret-store.ts new file mode 100644 index 0000000000..6fbf8f81b4 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/github-webhook-secret-store.ts @@ -0,0 +1,21 @@ +import type { GitHubWebhookSecretStore } from '../../core'; +import { getDurableConfigValue } from './durable-config'; +import { requiredEnvironmentValue } from './environment'; +import { GITHUB_WEBHOOK_SCOPE, GITHUB_WEBHOOK_SECRET_ID } from './keys'; + +export function createAwsDynamoDbGitHubWebhookSecretStore(): GitHubWebhookSecretStore { + return new AwsDynamoDbGitHubWebhookSecretStore(requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME')); +} + +class AwsDynamoDbGitHubWebhookSecretStore implements GitHubWebhookSecretStore { + constructor(private readonly tableName: string) {} + + async get(): Promise { + return await getDurableConfigValue( + this.tableName, + GITHUB_WEBHOOK_SCOPE, + GITHUB_WEBHOOK_SECRET_ID, + 'GitHub webhook secret', + ); + } +} diff --git a/lambdas/libs/storage-providers/aws/dynamodb/keys.test.ts b/lambdas/libs/storage-providers/aws/dynamodb/keys.test.ts new file mode 100644 index 0000000000..ac651df069 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/keys.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { + GITHUB_APP_CREDENTIALS_ID, + GITHUB_APP_SCOPE, + GITHUB_WEBHOOK_SCOPE, + GITHUB_WEBHOOK_SECRET_ID, + RUNNER_BOOTSTRAP_CONFIG_ID, + RUNNER_CONFIG_ID, + RUNNER_MATCHER_CONFIG_ID, + RUNNER_MATCHER_SCOPE, + runnerBootstrapScope, + runnerGroupId, + runnerGroupScope, + runnerStateId, + runnerStateScope, +} from './keys'; + +describe('aws_dynamodb storage keys', () => { + it('isolates whole-deployment durable records by capability', () => { + expect({ scope: GITHUB_APP_SCOPE, id: GITHUB_APP_CREDENTIALS_ID }).toEqual({ + scope: 'global#github-app', + id: 'github-app-credentials', + }); + expect({ scope: GITHUB_WEBHOOK_SCOPE, id: GITHUB_WEBHOOK_SECRET_ID }).toEqual({ + scope: 'global#webhook', + id: 'github-webhook-secret', + }); + expect({ scope: RUNNER_MATCHER_SCOPE, id: RUNNER_MATCHER_CONFIG_ID }).toEqual({ + scope: 'global#matcher', + id: 'runner-matcher-config', + }); + }); + + it('isolates entry records by access boundary', () => { + expect({ scope: runnerBootstrapScope('linux-x64'), id: RUNNER_BOOTSTRAP_CONFIG_ID }).toEqual({ + scope: 'entry#linux-x64#bootstrap', + id: 'runner-config', + }); + expect({ scope: runnerGroupScope('linux-x64'), id: runnerGroupId('Default') }).toEqual({ + scope: 'entry#linux-x64#runner-group', + id: 'runner-group#Default', + }); + expect(RUNNER_CONFIG_ID).toBe('config'); + expect({ scope: runnerStateScope('linux-x64'), id: runnerStateId('runner-123') }).toEqual({ + scope: 'entry#linux-x64#runner-state', + id: 'runner#runner-123', + }); + }); +}); diff --git a/lambdas/libs/storage-providers/aws/dynamodb/keys.ts b/lambdas/libs/storage-providers/aws/dynamodb/keys.ts new file mode 100644 index 0000000000..cef92af6bb --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/keys.ts @@ -0,0 +1,34 @@ +export const SCOPE_ATTRIBUTE = 'scope'; +export const ID_ATTRIBUTE = 'id'; +export const VALUE_ATTRIBUTE = 'value'; +export const EXPIRES_AT_ATTRIBUTE = 'expires_at'; + +export const GITHUB_APP_SCOPE = 'global#github-app'; +export const GITHUB_WEBHOOK_SCOPE = 'global#webhook'; +export const RUNNER_MATCHER_SCOPE = 'global#matcher'; + +export const GITHUB_APP_CREDENTIALS_ID = 'github-app-credentials'; +export const GITHUB_WEBHOOK_SECRET_ID = 'github-webhook-secret'; +export const RUNNER_MATCHER_CONFIG_ID = 'runner-matcher-config'; +export const RUNNER_BOOTSTRAP_CONFIG_ID = 'runner-config'; +export const RUNNER_CONFIG_ID = 'config'; + +export function runnerBootstrapScope(entryId: string): string { + return `entry#${entryId}#bootstrap`; +} + +export function runnerGroupScope(entryId: string): string { + return `entry#${entryId}#runner-group`; +} + +export function runnerStateScope(entryId: string): string { + return `entry#${entryId}#runner-state`; +} + +export function runnerStateId(runnerId: string): string { + return `runner#${runnerId}`; +} + +export function runnerGroupId(runnerGroupName: string): string { + return `runner-group#${runnerGroupName}`; +} diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.test.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.test.ts new file mode 100644 index 0000000000..1afb6c7fe9 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.test.ts @@ -0,0 +1,148 @@ +import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resetDynamoDbClient } from './client'; +import { createAwsDynamoDbRunnerConfigStore } from './runner-config-store'; + +const mockDynamoDbClient = mockClient(DynamoDBClient); +const cleanEnv = process.env; + +describe('aws_dynamodb runner config store', () => { + beforeEach(() => { + mockDynamoDbClient.reset(); + resetDynamoDbClient(); + process.env = { ...cleanEnv }; + process.env.AWS_REGION = 'eu-west-1'; + process.env.RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME = 'runner-state'; + delete process.env.RUNNER_CONFIG_DYNAMODB_ENTRY_ID; + process.env.RUNNER_CONFIG_DYNAMODB_TTL_SECONDS = '3600'; + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('creates an expiring runner config without overwriting an existing record', async () => { + const store = createAwsDynamoDbRunnerConfigStore(); + + await store.create({ + runnerId: 'runner-123', + value: 'encoded-jit-config', + accessScope: 'arn:aws:ec2:eu-west-1:123456789012:instance/i-123', + }); + + expect(store.maxWritesPerSecond).toBeUndefined(); + expect(mockDynamoDbClient).toHaveReceivedCommandWith(PutItemCommand, { + TableName: 'runner-state', + Item: { + scope: { S: 'arn:aws:ec2:eu-west-1:123456789012:instance/i-123' }, + id: { S: 'config' }, + value: { S: 'encoded-jit-config' }, + expires_at: { N: '1735693200' }, + }, + ConditionExpression: 'attribute_not_exists(#scope) AND attribute_not_exists(#id)', + ExpressionAttributeNames: { + '#scope': 'scope', + '#id': 'id', + }, + }); + }); + + it('stores provider-neutral metadata without exposing it as top-level attributes', async () => { + const store = createAwsDynamoDbRunnerConfigStore(); + + await store.create( + { + runnerId: 'runner-123', + value: 'registration-config', + accessScope: 'arn:aws:ec2:eu-west-1:123456789012:instance/i-123', + }, + { + metadata: [ + { key: 'InstanceId', value: 'i-123' }, + { key: 'Environment', value: 'test' }, + ], + }, + ); + + const command = mockDynamoDbClient.commandCalls(PutItemCommand)[0].args[0]; + expect(command.input.Item?.metadata).toEqual({ + L: [ + { M: { key: { S: 'InstanceId' }, value: { S: 'i-123' } } }, + { M: { key: { S: 'Environment' }, value: { S: 'test' } } }, + ], + }); + }); + + it('does not write metadata for an empty metadata list', async () => { + const store = createAwsDynamoDbRunnerConfigStore(); + + await store.create( + { + runnerId: 'runner-123', + value: 'registration-config', + accessScope: 'arn:aws:ec2:eu-west-1:123456789012:instance/i-123', + }, + { metadata: [] }, + ); + + const command = mockDynamoDbClient.commandCalls(PutItemCommand)[0].args[0]; + expect(command.input.Item).not.toHaveProperty('metadata'); + }); + + it('relies on DynamoDB TTL instead of scanning or deleting during housekeeping', async () => { + const store = createAwsDynamoDbRunnerConfigStore(); + + await expect(store.houseKeeper()).resolves.toBeUndefined(); + + expect(mockDynamoDbClient.calls()).toHaveLength(0); + }); + + it.each(['RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME', 'RUNNER_CONFIG_DYNAMODB_TTL_SECONDS'] as const)( + 'rejects a missing or blank %s', + (name) => { + delete process.env[name]; + expect(() => createAwsDynamoDbRunnerConfigStore()).toThrow(`Environment variable ${name} is not set`); + + process.env[name] = ' '; + expect(() => createAwsDynamoDbRunnerConfigStore()).toThrow(`Environment variable ${name} is not set`); + expect(mockDynamoDbClient.calls()).toHaveLength(0); + }, + ); + + it.each(['0', '-1', '1.5', 'not-a-number', '9007199254740992'])('rejects invalid TTL seconds %j', (ttlSeconds) => { + process.env.RUNNER_CONFIG_DYNAMODB_TTL_SECONDS = ttlSeconds; + + expect(() => createAwsDynamoDbRunnerConfigStore()).toThrow( + 'Environment variable RUNNER_CONFIG_DYNAMODB_TTL_SECONDS must be a positive integer', + ); + expect(mockDynamoDbClient.calls()).toHaveLength(0); + }); + + it.each([undefined, '', ' '])('rejects invalid access scope %j before writing', async (accessScope) => { + const store = createAwsDynamoDbRunnerConfigStore(); + + await expect(store.create({ runnerId: 'runner-123', value: 'sensitive-config', accessScope })).rejects.toThrow( + "Runner config field 'accessScope' must be a non-empty string for aws_dynamodb", + ); + expect(mockDynamoDbClient.calls()).toHaveLength(0); + }); + + it('propagates DynamoDB write errors without handling the stored value', async () => { + const error = new Error('conditional request failed'); + mockDynamoDbClient.on(PutItemCommand).rejects(error); + const store = createAwsDynamoDbRunnerConfigStore(); + + await expect( + store.create({ + runnerId: 'runner-123', + value: 'sensitive-config', + accessScope: 'arn:aws:ec2:eu-west-1:123456789012:instance/i-123', + }), + ).rejects.toBe(error); + }); +}); diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.ts new file mode 100644 index 0000000000..79de2b6b71 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-config-store.ts @@ -0,0 +1,66 @@ +import { PutItemCommand, type AttributeValue } from '@aws-sdk/client-dynamodb'; + +import type { RunnerConfigMetadata, RunnerConfigRecord, RunnerConfigStore } from '../../core'; +import { getDynamoDbClient } from './client'; +import { positiveIntegerEnvironmentValue, requiredEnvironmentValue } from './environment'; +import { EXPIRES_AT_ATTRIBUTE, ID_ATTRIBUTE, RUNNER_CONFIG_ID, SCOPE_ATTRIBUTE, VALUE_ATTRIBUTE } from './keys'; + +const METADATA_ATTRIBUTE = 'metadata'; + +interface AwsDynamoDbRunnerConfigStoreConfig { + tableName: string; + ttlSeconds: number; +} + +export function createAwsDynamoDbRunnerConfigStore(): RunnerConfigStore { + return new AwsDynamoDbRunnerConfigStore({ + tableName: requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME'), + ttlSeconds: positiveIntegerEnvironmentValue('RUNNER_CONFIG_DYNAMODB_TTL_SECONDS'), + }); +} + +class AwsDynamoDbRunnerConfigStore implements RunnerConfigStore { + constructor(private readonly config: AwsDynamoDbRunnerConfigStoreConfig) {} + + async create(record: RunnerConfigRecord, options: { metadata?: RunnerConfigMetadata[] } = {}): Promise { + if (typeof record.accessScope !== 'string' || record.accessScope.trim() === '') { + throw new Error("Runner config field 'accessScope' must be a non-empty string for aws_dynamodb"); + } + + const item: Record = { + [SCOPE_ATTRIBUTE]: { S: record.accessScope }, + [ID_ATTRIBUTE]: { S: RUNNER_CONFIG_ID }, + [VALUE_ATTRIBUTE]: { S: record.value }, + [EXPIRES_AT_ATTRIBUTE]: { + N: (Math.floor(Date.now() / 1000) + this.config.ttlSeconds).toString(), + }, + }; + + if (options.metadata && options.metadata.length > 0) { + item[METADATA_ATTRIBUTE] = { + L: options.metadata.map(({ key, value }) => ({ + M: { + key: { S: key }, + value: { S: value }, + }, + })), + }; + } + + await getDynamoDbClient().send( + new PutItemCommand({ + TableName: this.config.tableName, + Item: item, + ConditionExpression: 'attribute_not_exists(#scope) AND attribute_not_exists(#id)', + ExpressionAttributeNames: { + '#scope': SCOPE_ATTRIBUTE, + '#id': ID_ATTRIBUTE, + }, + }), + ); + } + + async houseKeeper(): Promise { + // DynamoDB TTL removes expired runner config records without a scan/delete job. + } +} diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.test.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.test.ts new file mode 100644 index 0000000000..2fbac5b69d --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.test.ts @@ -0,0 +1,108 @@ +import { DynamoDBClient, GetItemCommand, PutItemCommand } from '@aws-sdk/client-dynamodb'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest/vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { resetDynamoDbClient } from './client'; +import { createAwsDynamoDbRunnerGroupCacheStore } from './runner-group-cache-store'; + +const mockDynamoDbClient = mockClient(DynamoDBClient); +const cleanEnv = process.env; + +describe('aws_dynamodb runner group cache store', () => { + beforeEach(() => { + mockDynamoDbClient.reset(); + resetDynamoDbClient(); + process.env = { ...cleanEnv }; + process.env.AWS_REGION = 'eu-west-1'; + process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME = 'runner-configuration'; + process.env.RUNNER_CONFIG_DYNAMODB_ENTRY_ID = 'linux-x64'; + }); + + it('gets a runner group id with a strongly consistent projected read', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { S: '42' } } }); + const store = createAwsDynamoDbRunnerGroupCacheStore(); + + await expect(store.get('Default')).resolves.toBe(42); + expect(mockDynamoDbClient).toHaveReceivedCommandWith(GetItemCommand, { + TableName: 'runner-configuration', + Key: { + scope: { S: 'entry#linux-x64#runner-group' }, + id: { S: 'runner-group#Default' }, + }, + ConsistentRead: true, + ProjectionExpression: '#value', + ExpressionAttributeNames: { + '#value': 'value', + }, + }); + }); + + it('returns undefined when the runner group is not cached', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({}); + const store = createAwsDynamoDbRunnerGroupCacheStore(); + + await expect(store.get('Default')).resolves.toBeUndefined(); + }); + + it.each([ + [{ value: { N: '42' } }, 'non-string value'], + [{ value: { S: '42cached' } }, 'partially numeric value'], + [{ value: { S: '9007199254740992' } }, 'unsafe integer value'], + ])('rejects an invalid cached runner group id: %s', async (item) => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: item }); + const store = createAwsDynamoDbRunnerGroupCacheStore(); + + await expect(store.get('Default')).rejects.toThrow( + "Runner group cache item 'entry#linux-x64#runner-group/runner-group#Default' has an invalid value", + ); + }); + + it('creates a runner group cache record without overwriting an existing record', async () => { + const store = createAwsDynamoDbRunnerGroupCacheStore(); + + await store.create({ runnerGroupName: 'Default', runnerGroupId: 42 }); + + expect(mockDynamoDbClient).toHaveReceivedCommandWith(PutItemCommand, { + TableName: 'runner-configuration', + Item: { + scope: { S: 'entry#linux-x64#runner-group' }, + id: { S: 'runner-group#Default' }, + value: { S: '42' }, + }, + ConditionExpression: 'attribute_not_exists(#scope) AND attribute_not_exists(#id)', + ExpressionAttributeNames: { + '#scope': 'scope', + '#id': 'id', + }, + }); + }); + + it.each(['RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME', 'RUNNER_CONFIG_DYNAMODB_ENTRY_ID'] as const)( + 'rejects a missing or blank %s', + (name) => { + delete process.env[name]; + expect(() => createAwsDynamoDbRunnerGroupCacheStore()).toThrow(`Environment variable ${name} is not set`); + + process.env[name] = ' '; + expect(() => createAwsDynamoDbRunnerGroupCacheStore()).toThrow(`Environment variable ${name} is not set`); + expect(mockDynamoDbClient.calls()).toHaveLength(0); + }, + ); + + it('propagates DynamoDB read errors', async () => { + const error = new Error('read failed'); + mockDynamoDbClient.on(GetItemCommand).rejects(error); + const store = createAwsDynamoDbRunnerGroupCacheStore(); + + await expect(store.get('Default')).rejects.toBe(error); + }); + + it('propagates DynamoDB write errors', async () => { + const error = new Error('conditional request failed'); + mockDynamoDbClient.on(PutItemCommand).rejects(error); + const store = createAwsDynamoDbRunnerGroupCacheStore(); + + await expect(store.create({ runnerGroupName: 'Default', runnerGroupId: 42 })).rejects.toBe(error); + }); +}); diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.ts new file mode 100644 index 0000000000..a90a79e46e --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-group-cache-store.ts @@ -0,0 +1,80 @@ +import { GetItemCommand, PutItemCommand } from '@aws-sdk/client-dynamodb'; + +import type { RunnerGroupCacheRecord, RunnerGroupCacheStore } from '../../core'; +import { getDynamoDbClient } from './client'; +import { requiredEnvironmentValue } from './environment'; +import { + ID_ATTRIBUTE, + runnerGroupId as runnerGroupItemId, + runnerGroupScope, + SCOPE_ATTRIBUTE, + VALUE_ATTRIBUTE, +} from './keys'; + +interface AwsDynamoDbRunnerGroupCacheStoreConfig { + tableName: string; + scope: string; +} + +export function createAwsDynamoDbRunnerGroupCacheStore(): RunnerGroupCacheStore { + return new AwsDynamoDbRunnerGroupCacheStore({ + tableName: requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME'), + scope: runnerGroupScope(requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_ENTRY_ID')), + }); +} + +class AwsDynamoDbRunnerGroupCacheStore implements RunnerGroupCacheStore { + constructor(private readonly config: AwsDynamoDbRunnerGroupCacheStoreConfig) {} + + async get(runnerGroupName: string): Promise { + const id = runnerGroupItemId(runnerGroupName); + const result = await getDynamoDbClient().send( + new GetItemCommand({ + TableName: this.config.tableName, + Key: { + [SCOPE_ATTRIBUTE]: { S: this.config.scope }, + [ID_ATTRIBUTE]: { S: id }, + }, + ConsistentRead: true, + ProjectionExpression: '#value', + ExpressionAttributeNames: { + '#value': VALUE_ATTRIBUTE, + }, + }), + ); + + if (!result.Item) { + return undefined; + } + + const value = result.Item[VALUE_ATTRIBUTE]?.S; + if (value === undefined || !/^\d+$/.test(value)) { + throw new Error(`Runner group cache item '${this.config.scope}/${id}' has an invalid value`); + } + + const runnerGroupId = Number(value); + if (!Number.isSafeInteger(runnerGroupId)) { + throw new Error(`Runner group cache item '${this.config.scope}/${id}' has an invalid value`); + } + + return runnerGroupId; + } + + async create(record: RunnerGroupCacheRecord): Promise { + await getDynamoDbClient().send( + new PutItemCommand({ + TableName: this.config.tableName, + Item: { + [SCOPE_ATTRIBUTE]: { S: this.config.scope }, + [ID_ATTRIBUTE]: { S: runnerGroupItemId(record.runnerGroupName) }, + [VALUE_ATTRIBUTE]: { S: record.runnerGroupId.toString() }, + }, + ConditionExpression: 'attribute_not_exists(#scope) AND attribute_not_exists(#id)', + ExpressionAttributeNames: { + '#scope': SCOPE_ATTRIBUTE, + '#id': ID_ATTRIBUTE, + }, + }), + ); + } +} diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.test.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.test.ts new file mode 100644 index 0000000000..66e8d07c2e --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.test.ts @@ -0,0 +1,95 @@ +import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest/vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { resetDynamoDbClient } from './client'; +import { createAwsDynamoDbRunnerMatcherConfigStore } from './runner-matcher-config-store'; + +const mockDynamoDbClient = mockClient(DynamoDBClient); +const cleanEnv = process.env; + +describe('aws_dynamodb runner matcher config store', () => { + beforeEach(() => { + mockDynamoDbClient.reset(); + resetDynamoDbClient(); + process.env = { ...cleanEnv }; + process.env.AWS_REGION = 'eu-west-1'; + process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME = 'runner-configuration'; + }); + + it('gets the matcher config with a strongly consistent projected read', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { S: '[{"id":"runner"}]' } } }); + const store = createAwsDynamoDbRunnerMatcherConfigStore(); + + await expect(store.get()).resolves.toBe('[{"id":"runner"}]'); + expect(mockDynamoDbClient).toHaveReceivedCommandWith(GetItemCommand, { + TableName: 'runner-configuration', + Key: { + scope: { S: 'global#matcher' }, + id: { S: 'runner-matcher-config' }, + }, + ConsistentRead: true, + ProjectionExpression: '#value', + ExpressionAttributeNames: { + '#value': 'value', + }, + }); + }); + + it('returns an empty stored string for validation by the webhook config loader', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { S: '' } } }); + const store = createAwsDynamoDbRunnerMatcherConfigStore(); + + await expect(store.get()).resolves.toBe(''); + }); + + it('rejects a missing matcher config item', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({}); + const store = createAwsDynamoDbRunnerMatcherConfigStore(); + + await expect(store.get()).rejects.toThrow( + "Runner matcher config item 'global#matcher/runner-matcher-config' was not found", + ); + }); + + it('rejects a matcher config item without a string value', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { N: '1' } } }); + const store = createAwsDynamoDbRunnerMatcherConfigStore(); + + await expect(store.get()).rejects.toThrow( + "Runner matcher config item 'global#matcher/runner-matcher-config' does not contain a string value", + ); + }); + + it('rejects a missing or blank durable table name', () => { + delete process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME; + expect(() => createAwsDynamoDbRunnerMatcherConfigStore()).toThrow( + 'Environment variable RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME is not set', + ); + + process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME = ' '; + expect(() => createAwsDynamoDbRunnerMatcherConfigStore()).toThrow( + 'Environment variable RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME is not set', + ); + expect(mockDynamoDbClient.calls()).toHaveLength(0); + }); + + it('propagates DynamoDB read errors', async () => { + const error = new Error('read failed'); + mockDynamoDbClient.on(GetItemCommand).rejects(error); + const store = createAwsDynamoDbRunnerMatcherConfigStore(); + + await expect(store.get()).rejects.toBe(error); + }); + + it('reuses the memoised client across reads', async () => { + mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { S: '[]' } } }); + const store = createAwsDynamoDbRunnerMatcherConfigStore(); + + await store.get(); + await store.get(); + + expect(mockDynamoDbClient.commandCalls(GetItemCommand)).toHaveLength(2); + }); +}); diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.ts new file mode 100644 index 0000000000..f840345983 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-matcher-config-store.ts @@ -0,0 +1,27 @@ +import type { RunnerMatcherConfigStore } from '../../core'; +import { getDurableConfigValue } from './durable-config'; +import { requiredEnvironmentValue } from './environment'; +import { RUNNER_MATCHER_CONFIG_ID, RUNNER_MATCHER_SCOPE } from './keys'; + +interface AwsDynamoDbRunnerMatcherConfigStoreConfig { + tableName: string; +} + +export function createAwsDynamoDbRunnerMatcherConfigStore(): RunnerMatcherConfigStore { + return new AwsDynamoDbRunnerMatcherConfigStore({ + tableName: requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME'), + }); +} + +class AwsDynamoDbRunnerMatcherConfigStore implements RunnerMatcherConfigStore { + constructor(private readonly config: AwsDynamoDbRunnerMatcherConfigStoreConfig) {} + + async get(): Promise { + return await getDurableConfigValue( + this.config.tableName, + RUNNER_MATCHER_SCOPE, + RUNNER_MATCHER_CONFIG_ID, + 'Runner matcher config', + ); + } +} diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-state-store.test.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-state-store.test.ts new file mode 100644 index 0000000000..75e74ab526 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-state-store.test.ts @@ -0,0 +1,451 @@ +import { + ConditionalCheckFailedException, + DeleteItemCommand, + DynamoDBClient, + PutItemCommand, + QueryCommand, + UpdateItemCommand, + type AttributeValue, +} from '@aws-sdk/client-dynamodb'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { CreateRunnerStateRecord } from '../../core'; +import { resetDynamoDbClient } from './client'; +import { createAwsDynamoDbRunnerStateStore } from './runner-state-store'; + +const mockDynamoDbClient = mockClient(DynamoDBClient); +const cleanEnv = process.env; + +describe('aws_dynamodb runner state store', () => { + beforeEach(() => { + mockDynamoDbClient.reset(); + resetDynamoDbClient(); + process.env = { ...cleanEnv }; + process.env.AWS_REGION = 'eu-west-1'; + process.env.RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME = 'runner-state'; + process.env.RUNNER_CONFIG_DYNAMODB_ENTRY_ID = 'linux-x64'; + process.env.RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TTL_SECONDS = '86400'; + mockDynamoDbClient.on(PutItemCommand).resolves({}); + mockDynamoDbClient.on(UpdateItemCommand).resolves({}); + mockDynamoDbClient.on(DeleteItemCommand).resolves({}); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('creates a provisioning record without a secret payload or overwrite', async () => { + const store = createAwsDynamoDbRunnerStateStore(); + + await store.create(createRecord()); + + expect(mockDynamoDbClient).toHaveReceivedCommandWith(PutItemCommand, { + TableName: 'runner-state', + Item: { + scope: { S: 'entry#linux-x64#runner-state' }, + id: { S: 'runner#runner-123' }, + runner_id: { S: 'runner-123' }, + compute_provider: { S: 'aws_ec2' }, + compute_resource_id: { S: 'i-123' }, + runner_name: { S: 'ghr-runner-123' }, + runner_labels: { L: [{ S: 'linux' }, { S: 'x64' }] }, + runner_owner: { S: 'github-aws-runners' }, + runner_type: { S: 'Org' }, + state: { S: 'provisioning' }, + created_at: { S: '2025-01-01T00:00:00.000Z' }, + updated_at: { S: '2025-01-01T00:00:00.000Z' }, + expires_at: { N: '1735776000' }, + metadata: { + L: [{ M: { key: { S: 'Environment' }, value: { S: 'test' } } }], + }, + }, + ConditionExpression: 'attribute_not_exists(#scope) AND attribute_not_exists(#id)', + ExpressionAttributeNames: { '#scope': 'scope', '#id': 'id' }, + }); + const item = mockDynamoDbClient.commandCalls(PutItemCommand)[0].args[0].input.Item; + expect(item).not.toHaveProperty('value'); + }); + + it('omits optional attributes when provisioning data is not available yet', async () => { + const store = createAwsDynamoDbRunnerStateStore(); + const record = createRecord(); + delete record.runnerName; + delete record.runnerLabels; + delete record.metadata; + + await store.create(record); + + const item = mockDynamoDbClient.commandCalls(PutItemCommand)[0].args[0].input.Item; + expect(item).not.toHaveProperty('runner_name'); + expect(item).not.toHaveProperty('runner_labels'); + expect(item).not.toHaveProperty('metadata'); + }); + + it('preserves empty provider-neutral metadata values', async () => { + const store = createAwsDynamoDbRunnerStateStore(); + + await store.create({ ...createRecord(), metadata: [{ key: 'OptionalTag', value: '' }] }); + + expect(mockDynamoDbClient.commandCalls(PutItemCommand)[0].args[0].input.Item?.metadata).toEqual({ + L: [{ M: { key: { S: 'OptionalTag' }, value: { S: '' } } }], + }); + }); + + it('activates only provisioning records and atomically adds GitHub identity metadata', async () => { + const store = createAwsDynamoDbRunnerStateStore(); + + await store.activate('runner-123', { + githubRunnerId: '9876', + runnerName: 'jit-runner', + runnerLabels: ['linux', 'arm64'], + metadata: [{ key: 'zone', value: 'eu-west-1a' }], + }); + + expect(mockDynamoDbClient).toHaveReceivedCommandWith(UpdateItemCommand, { + TableName: 'runner-state', + Key: { scope: { S: 'entry#linux-x64#runner-state' }, id: { S: 'runner#runner-123' } }, + UpdateExpression: + 'SET #state = :state, #updatedAt = :updatedAt, #runnerName = :runnerName, #runnerLabels = :runnerLabels, #githubRunnerId = :githubRunnerId, #metadata = :metadata REMOVE #expiresAt', + ConditionExpression: 'attribute_exists(#scope) AND attribute_exists(#id) AND #state IN (:expectedState0)', + ExpressionAttributeNames: { + '#scope': 'scope', + '#id': 'id', + '#state': 'state', + '#updatedAt': 'updated_at', + '#expiresAt': 'expires_at', + '#runnerName': 'runner_name', + '#runnerLabels': 'runner_labels', + '#githubRunnerId': 'github_runner_id', + '#metadata': 'metadata', + }, + ExpressionAttributeValues: { + ':state': { S: 'active' }, + ':updatedAt': { S: '2025-01-01T00:00:00.000Z' }, + ':runnerName': { S: 'jit-runner' }, + ':runnerLabels': { L: [{ S: 'linux' }, { S: 'arm64' }] }, + ':githubRunnerId': { S: '9876' }, + ':metadata': { L: [{ M: { key: { S: 'zone' }, value: { S: 'eu-west-1a' } } }] }, + ':expectedState0': { S: 'provisioning' }, + }, + }); + }); + + it('records GitHub identity while the runner remains provisioning', async () => { + const store = createAwsDynamoDbRunnerStateStore(); + + await store.recordGitHubIdentity('runner-123', { + githubRunnerId: '9876', + runnerName: 'jit-runner', + runnerLabels: ['linux', 'arm64'], + }); + + expect(mockDynamoDbClient).toHaveReceivedCommandWith(UpdateItemCommand, { + TableName: 'runner-state', + Key: { scope: { S: 'entry#linux-x64#runner-state' }, id: { S: 'runner#runner-123' } }, + UpdateExpression: + 'SET #state = :state, #updatedAt = :updatedAt, #expiresAt = :expiresAt, #runnerName = :runnerName, #runnerLabels = :runnerLabels, #githubRunnerId = :githubRunnerId', + ConditionExpression: 'attribute_exists(#scope) AND attribute_exists(#id) AND #state IN (:expectedState0)', + ExpressionAttributeValues: expect.objectContaining({ + ':state': { S: 'provisioning' }, + ':updatedAt': { S: '2025-01-01T00:00:00.000Z' }, + ':expiresAt': { N: '1735776000' }, + ':runnerName': { S: 'jit-runner' }, + ':runnerLabels': { L: [{ S: 'linux' }, { S: 'arm64' }] }, + ':githubRunnerId': { S: '9876' }, + ':expectedState0': { S: 'provisioning' }, + }), + }); + }); + + it('lists all pages for an entry with a strongly consistent query', async () => { + const lastKey = { scope: { S: 'entry#linux-x64#runner-state' }, id: { S: 'runner#runner-123' } }; + mockDynamoDbClient + .on(QueryCommand) + .resolvesOnce({ Items: [storedRecord()], LastEvaluatedKey: lastKey }) + .resolvesOnce({ Items: [storedRecord({ runnerId: 'runner-456', resourceId: 'vm-456' })] }); + const store = createAwsDynamoDbRunnerStateStore(); + + await expect(store.list()).resolves.toEqual([ + expectedRecord(), + expectedRecord({ runnerId: 'runner-456', resourceId: 'vm-456' }), + ]); + const calls = mockDynamoDbClient.commandCalls(QueryCommand); + expect(calls).toHaveLength(2); + expect(calls[0].args[0].input).toMatchObject({ + TableName: 'runner-state', + KeyConditionExpression: '#scope = :scope AND begins_with(#id, :runner)', + ConsistentRead: true, + ExpressionAttributeValues: { + ':scope': { S: 'entry#linux-x64#runner-state' }, + ':runner': { S: 'runner#' }, + }, + }); + expect(calls[1].args[0].input.ExclusiveStartKey).toEqual(lastKey); + }); + + it('filters by compute provider while retaining an entry-scoped key query', async () => { + mockDynamoDbClient.on(QueryCommand).resolves({ Items: [] }); + const store = createAwsDynamoDbRunnerStateStore(); + + await store.list({ computeProvider: 'aws_microvm' }); + + expect(mockDynamoDbClient).toHaveReceivedCommandWith(QueryCommand, { + FilterExpression: '#computeProvider = :computeProvider', + ExpressionAttributeNames: { + '#scope': 'scope', + '#id': 'id', + '#computeProvider': 'compute_provider', + }, + ExpressionAttributeValues: { + ':scope': { S: 'entry#linux-x64#runner-state' }, + ':runner': { S: 'runner#' }, + ':computeProvider': { S: 'aws_microvm' }, + }, + }); + }); + + it('maps optional fields as absent and rejects corrupt lifecycle state', async () => { + const item = storedRecord(); + delete item.runner_name; + delete item.runner_labels; + delete item.github_runner_id; + delete item.metadata; + mockDynamoDbClient.on(QueryCommand).resolves({ Items: [item] }); + const store = createAwsDynamoDbRunnerStateStore(); + + await expect(store.list()).resolves.toEqual([ + expect.objectContaining({ + runnerName: undefined, + runnerLabels: undefined, + githubRunnerId: undefined, + metadata: undefined, + }), + ]); + + item.state = { S: 'unknown' }; + mockDynamoDbClient.on(QueryCommand).resolves({ Items: [item] }); + await expect(store.list()).rejects.toThrow( + "Runner state item 'entry#linux-x64#runner-state/runner#runner-123' has an invalid 'state' attribute", + ); + }); + + it('marks and unmarks orphan state with conditional transitions', async () => { + const store = createAwsDynamoDbRunnerStateStore(); + + await store.markOrphan('runner-123'); + await store.unmarkOrphan('runner-123'); + + const calls = mockDynamoDbClient.commandCalls(UpdateItemCommand); + expect(calls[0].args[0].input.ExpressionAttributeValues).toMatchObject({ + ':state': { S: 'orphan' }, + ':expectedState0': { S: 'active' }, + }); + expect(calls[0].args[0].input.UpdateExpression).toContain('REMOVE #expiresAt'); + expect(calls[0].args[0].input.ExpressionAttributeValues).not.toHaveProperty(':expiresAt'); + expect(calls[1].args[0].input.ExpressionAttributeValues).toMatchObject({ + ':state': { S: 'active' }, + ':expectedState0': { S: 'orphan' }, + }); + expect(calls[1].args[0].input.UpdateExpression).toContain('REMOVE #expiresAt'); + expect(calls[1].args[0].input.ExpressionAttributeValues).not.toHaveProperty(':expiresAt'); + }); + + it.each(['provisioning', 'active', 'orphan'] as const)( + 'claims termination and returns the prior %s state', + async (state) => { + mockDynamoDbClient.on(UpdateItemCommand).resolves({ Attributes: { state: { S: state } } }); + const store = createAwsDynamoDbRunnerStateStore(); + + await expect(store.beginTermination('runner-123')).resolves.toBe(state); + expect(mockDynamoDbClient).toHaveReceivedCommandWith(UpdateItemCommand, { + ReturnValues: 'ALL_OLD', + ConditionExpression: + 'attribute_exists(#scope) AND attribute_exists(#id) AND (#state IN (:provisioning, :active, :orphan) OR (#state = :terminating AND #updatedAt < :staleBefore))', + ExpressionAttributeValues: expect.objectContaining({ + ':terminating': { S: 'terminating' }, + ':expiresAt': { N: '1735776000' }, + ':provisioning': { S: 'provisioning' }, + ':active': { S: 'active' }, + ':orphan': { S: 'orphan' }, + ':staleBefore': { S: '2024-12-31T23:44:00.000Z' }, + }), + }); + }, + ); + + it('reclaims a stale terminating record on a later invocation without allowing an immediate double claim', async () => { + mockDynamoDbClient + .on(UpdateItemCommand) + .resolvesOnce({ Attributes: { state: { S: 'active' } } }) + .rejectsOnce(new ConditionalCheckFailedException({ $metadata: {}, message: 'lease is still held' })) + .resolvesOnce({ Attributes: { state: { S: 'terminating' } } }); + const store = createAwsDynamoDbRunnerStateStore(); + + await expect(store.beginTermination('runner-123')).resolves.toBe('active'); + await expect(store.beginTermination('runner-123')).resolves.toBeUndefined(); + vi.advanceTimersByTime(17 * 60 * 1000); + await expect(store.beginTermination('runner-123')).resolves.toBe('terminating'); + + const reclaimed = mockDynamoDbClient.commandCalls(UpdateItemCommand)[2].args[0].input; + expect(reclaimed.ExpressionAttributeValues?.[':staleBefore']).toEqual({ + S: '2025-01-01T00:01:00.000Z', + }); + }); + + it('returns undefined when another invocation already owns termination', async () => { + mockDynamoDbClient + .on(UpdateItemCommand) + .rejects(new ConditionalCheckFailedException({ $metadata: {}, message: 'condition failed' })); + const store = createAwsDynamoDbRunnerStateStore(); + + await expect(store.beginTermination('runner-123')).resolves.toBeUndefined(); + }); + + it('restores the safety TTL when cancellation returns a runner to provisioning', async () => { + const store = createAwsDynamoDbRunnerStateStore(); + + await store.cancelTermination('runner-123', 'provisioning'); + + expect(mockDynamoDbClient).toHaveReceivedCommandWith(UpdateItemCommand, { + UpdateExpression: 'SET #state = :state, #updatedAt = :updatedAt, #expiresAt = :expiresAt', + ExpressionAttributeValues: expect.objectContaining({ + ':state': { S: 'provisioning' }, + ':expiresAt': { N: '1735776000' }, + ':expectedState0': { S: 'terminating' }, + }), + }); + }); + + it.each(['active', 'orphan'] as const)('removes the safety TTL when cancellation restores %s', async (state) => { + const store = createAwsDynamoDbRunnerStateStore(); + + await store.cancelTermination('runner-123', state); + + expect(mockDynamoDbClient).toHaveReceivedCommandWith(UpdateItemCommand, { + UpdateExpression: 'SET #state = :state, #updatedAt = :updatedAt REMOVE #expiresAt', + ExpressionAttributeValues: expect.objectContaining({ + ':state': { S: state }, + ':expectedState0': { S: 'terminating' }, + }), + }); + const values = mockDynamoDbClient.commandCalls(UpdateItemCommand)[0].args[0].input.ExpressionAttributeValues; + expect(values).not.toHaveProperty(':expiresAt'); + }); + + it('deletes only a record whose termination was claimed', async () => { + const store = createAwsDynamoDbRunnerStateStore(); + + await store.delete('runner-123'); + + expect(mockDynamoDbClient).toHaveReceivedCommandWith(DeleteItemCommand, { + TableName: 'runner-state', + Key: { scope: { S: 'entry#linux-x64#runner-state' }, id: { S: 'runner#runner-123' } }, + ConditionExpression: '#state = :terminating', + ExpressionAttributeNames: { '#state': 'state' }, + ExpressionAttributeValues: { ':terminating': { S: 'terminating' } }, + }); + }); + + it.each([ + 'RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME', + 'RUNNER_CONFIG_DYNAMODB_ENTRY_ID', + 'RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TTL_SECONDS', + ] as const)('rejects missing provider environment %s', (name) => { + delete process.env[name]; + + expect(() => createAwsDynamoDbRunnerStateStore()).toThrow(`Environment variable ${name} is not set`); + expect(mockDynamoDbClient.calls()).toHaveLength(0); + }); + + it.each(['0', '-1', '1.5', 'not-a-number'])('rejects invalid state TTL %j', (ttl) => { + process.env.RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TTL_SECONDS = ttl; + + expect(() => createAwsDynamoDbRunnerStateStore()).toThrow( + 'Environment variable RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TTL_SECONDS must be a positive integer', + ); + }); + + it('validates records before writing', async () => { + const store = createAwsDynamoDbRunnerStateStore(); + + await expect(store.create({ ...createRecord(), computeProvider: ' ' })).rejects.toThrow( + "Runner state field 'computeProvider' must be a non-empty string", + ); + await expect(store.create({ ...createRecord(), runnerType: 'Team' as never })).rejects.toThrow( + "Runner state field 'runnerType' must be 'Org' or 'Repo'", + ); + await expect(store.activate('runner-123', { runnerLabels: [''] })).rejects.toThrow( + "Runner state field 'runnerLabels' must be a non-empty string", + ); + await expect(store.recordGitHubIdentity('runner-123', { githubRunnerId: '' })).rejects.toThrow( + "Runner state field 'githubRunnerId' must be a non-empty string", + ); + await expect(store.list({ computeProvider: '' })).rejects.toThrow( + "Runner state field 'computeProvider' must be a non-empty string", + ); + expect(mockDynamoDbClient.calls()).toHaveLength(0); + }); + + it('propagates non-conditional lifecycle errors', async () => { + const error = new Error('service unavailable'); + mockDynamoDbClient.on(UpdateItemCommand).rejects(error); + const store = createAwsDynamoDbRunnerStateStore(); + + await expect(store.beginTermination('runner-123')).rejects.toBe(error); + }); +}); + +function createRecord(): CreateRunnerStateRecord { + return { + runnerId: 'runner-123', + computeProvider: 'aws_ec2', + computeResourceId: 'i-123', + runnerName: 'ghr-runner-123', + runnerLabels: ['linux', 'x64'], + runnerOwner: 'github-aws-runners', + runnerType: 'Org', + metadata: [{ key: 'Environment', value: 'test' }], + }; +} + +function storedRecord(options: { runnerId?: string; resourceId?: string } = {}): Record { + const runnerId = options.runnerId ?? 'runner-123'; + return { + scope: { S: 'entry#linux-x64#runner-state' }, + id: { S: `runner#${runnerId}` }, + runner_id: { S: runnerId }, + compute_provider: { S: 'aws_ec2' }, + compute_resource_id: { S: options.resourceId ?? 'i-123' }, + runner_name: { S: `ghr-${runnerId}` }, + runner_labels: { L: [{ S: 'linux' }, { S: 'x64' }] }, + github_runner_id: { S: '9876' }, + runner_owner: { S: 'github-aws-runners' }, + runner_type: { S: 'Org' }, + state: { S: 'active' }, + created_at: { S: '2025-01-01T00:00:00.000Z' }, + updated_at: { S: '2025-01-01T00:01:00.000Z' }, + metadata: { L: [{ M: { key: { S: 'Environment' }, value: { S: 'test' } } }] }, + }; +} + +function expectedRecord(options: { runnerId?: string; resourceId?: string } = {}) { + const runnerId = options.runnerId ?? 'runner-123'; + return { + runnerId, + computeProvider: 'aws_ec2', + computeResourceId: options.resourceId ?? 'i-123', + runnerName: `ghr-${runnerId}`, + runnerLabels: ['linux', 'x64'], + githubRunnerId: '9876', + runnerOwner: 'github-aws-runners', + runnerType: 'Org', + state: 'active', + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:01:00.000Z', + metadata: [{ key: 'Environment', value: 'test' }], + }; +} diff --git a/lambdas/libs/storage-providers/aws/dynamodb/runner-state-store.ts b/lambdas/libs/storage-providers/aws/dynamodb/runner-state-store.ts new file mode 100644 index 0000000000..7263aa93c1 --- /dev/null +++ b/lambdas/libs/storage-providers/aws/dynamodb/runner-state-store.ts @@ -0,0 +1,556 @@ +import { + ConditionalCheckFailedException, + DeleteItemCommand, + PutItemCommand, + QueryCommand, + UpdateItemCommand, + type AttributeValue, +} from '@aws-sdk/client-dynamodb'; + +import type { + CreateRunnerStateRecord, + RunnerConfigMetadata, + RunnerGitHubIdentity, + RunnerLifecycleState, + RunnerStateActivation, + RunnerStateFilter, + RunnerStateRecord, + RunnerStateStore, + RunnerType, +} from '../../core'; +import { getDynamoDbClient } from './client'; +import { positiveIntegerEnvironmentValue, requiredEnvironmentValue } from './environment'; +import { EXPIRES_AT_ATTRIBUTE, ID_ATTRIBUTE, runnerStateId, runnerStateScope, SCOPE_ATTRIBUTE } from './keys'; + +const RUNNER_ID_PREFIX = 'runner#'; +const RUNNER_ID_ATTRIBUTE = 'runner_id'; +const COMPUTE_PROVIDER_ATTRIBUTE = 'compute_provider'; +const COMPUTE_RESOURCE_ID_ATTRIBUTE = 'compute_resource_id'; +const RUNNER_NAME_ATTRIBUTE = 'runner_name'; +const RUNNER_LABELS_ATTRIBUTE = 'runner_labels'; +const GITHUB_RUNNER_ID_ATTRIBUTE = 'github_runner_id'; +const RUNNER_OWNER_ATTRIBUTE = 'runner_owner'; +const RUNNER_TYPE_ATTRIBUTE = 'runner_type'; +const STATE_ATTRIBUTE = 'state'; +const CREATED_AT_ATTRIBUTE = 'created_at'; +const UPDATED_AT_ATTRIBUTE = 'updated_at'; +const METADATA_ATTRIBUTE = 'metadata'; +// AWS Lambda can run for at most 15 minutes. One extra minute prevents a second +// invocation from reclaiming a termination while the original can still be running. +const TERMINATION_CLAIM_LEASE_MILLISECONDS = 16 * 60 * 1000; + +interface AwsDynamoDbRunnerStateStoreConfig { + tableName: string; + scope: string; + ttlSeconds: number; +} + +export function createAwsDynamoDbRunnerStateStore(): RunnerStateStore { + return new AwsDynamoDbRunnerStateStore({ + tableName: requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME'), + scope: runnerStateScope(requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_ENTRY_ID')), + ttlSeconds: positiveIntegerEnvironmentValue('RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TTL_SECONDS'), + }); +} + +class AwsDynamoDbRunnerStateStore implements RunnerStateStore { + constructor(private readonly config: AwsDynamoDbRunnerStateStoreConfig) {} + + async create(record: CreateRunnerStateRecord): Promise { + validateCreateRecord(record); + const now = new Date(); + const timestamp = now.toISOString(); + const item: Record = { + [SCOPE_ATTRIBUTE]: { S: this.config.scope }, + [ID_ATTRIBUTE]: { S: runnerStateId(record.runnerId) }, + [RUNNER_ID_ATTRIBUTE]: { S: record.runnerId }, + [COMPUTE_PROVIDER_ATTRIBUTE]: { S: record.computeProvider }, + [COMPUTE_RESOURCE_ID_ATTRIBUTE]: { S: record.computeResourceId }, + [RUNNER_OWNER_ATTRIBUTE]: { S: record.runnerOwner }, + [RUNNER_TYPE_ATTRIBUTE]: { S: record.runnerType }, + [STATE_ATTRIBUTE]: { S: 'provisioning' }, + [CREATED_AT_ATTRIBUTE]: { S: timestamp }, + [UPDATED_AT_ATTRIBUTE]: { S: timestamp }, + [EXPIRES_AT_ATTRIBUTE]: { N: expiresAt(now, this.config.ttlSeconds) }, + }; + + setOptionalString(item, RUNNER_NAME_ATTRIBUTE, record.runnerName); + setOptionalStringList(item, RUNNER_LABELS_ATTRIBUTE, record.runnerLabels); + setMetadata(item, record.metadata); + + await getDynamoDbClient().send( + new PutItemCommand({ + TableName: this.config.tableName, + Item: item, + ConditionExpression: 'attribute_not_exists(#scope) AND attribute_not_exists(#id)', + ExpressionAttributeNames: { + '#scope': SCOPE_ATTRIBUTE, + '#id': ID_ATTRIBUTE, + }, + }), + ); + } + + async activate(runnerId: string, activation: RunnerStateActivation = {}): Promise { + validateNonEmptyString(runnerId, 'runnerId'); + validateActivation(activation); + await this.transition(runnerId, ['provisioning'], 'active', activation); + } + + async recordGitHubIdentity(runnerId: string, identity: RunnerGitHubIdentity): Promise { + validateNonEmptyString(identity.githubRunnerId, 'githubRunnerId'); + validateActivation(identity); + await this.transition(runnerId, ['provisioning'], 'provisioning', identity); + } + + async list(filter: RunnerStateFilter = {}): Promise { + if (filter.computeProvider !== undefined) { + validateNonEmptyString(filter.computeProvider, 'computeProvider'); + } + + const records: RunnerStateRecord[] = []; + let exclusiveStartKey: Record | undefined; + + do { + const expressionAttributeNames: Record = { + '#scope': SCOPE_ATTRIBUTE, + '#id': ID_ATTRIBUTE, + }; + const expressionAttributeValues: Record = { + ':scope': { S: this.config.scope }, + ':runner': { S: RUNNER_ID_PREFIX }, + }; + + if (filter.computeProvider !== undefined) { + expressionAttributeNames['#computeProvider'] = COMPUTE_PROVIDER_ATTRIBUTE; + expressionAttributeValues[':computeProvider'] = { S: filter.computeProvider }; + } + + const result = await getDynamoDbClient().send( + new QueryCommand({ + TableName: this.config.tableName, + KeyConditionExpression: '#scope = :scope AND begins_with(#id, :runner)', + FilterExpression: filter.computeProvider === undefined ? undefined : '#computeProvider = :computeProvider', + ExpressionAttributeNames: expressionAttributeNames, + ExpressionAttributeValues: expressionAttributeValues, + ConsistentRead: true, + ExclusiveStartKey: exclusiveStartKey, + }), + ); + + for (const item of result.Items ?? []) { + records.push(parseRunnerStateRecord(item, this.config.scope)); + } + exclusiveStartKey = result.LastEvaluatedKey; + } while (exclusiveStartKey !== undefined); + + return records; + } + + async markOrphan(runnerId: string): Promise { + await this.transition(runnerId, ['active'], 'orphan'); + } + + async unmarkOrphan(runnerId: string): Promise { + await this.transition(runnerId, ['orphan'], 'active'); + } + + async beginTermination(runnerId: string): Promise { + validateNonEmptyString(runnerId, 'runnerId'); + const now = new Date(); + const staleBefore = new Date(now.getTime() - TERMINATION_CLAIM_LEASE_MILLISECONDS).toISOString(); + try { + const previous = ( + await getDynamoDbClient().send( + new UpdateItemCommand({ + TableName: this.config.tableName, + Key: this.key(runnerId), + UpdateExpression: 'SET #state = :terminating, #updatedAt = :updatedAt, #expiresAt = :expiresAt', + ConditionExpression: + 'attribute_exists(#scope) AND attribute_exists(#id) AND (#state IN (:provisioning, :active, :orphan) OR (#state = :terminating AND #updatedAt < :staleBefore))', + ExpressionAttributeNames: { + '#scope': SCOPE_ATTRIBUTE, + '#id': ID_ATTRIBUTE, + '#state': STATE_ATTRIBUTE, + '#updatedAt': UPDATED_AT_ATTRIBUTE, + '#expiresAt': EXPIRES_AT_ATTRIBUTE, + }, + ExpressionAttributeValues: { + ':provisioning': { S: 'provisioning' }, + ':active': { S: 'active' }, + ':orphan': { S: 'orphan' }, + ':terminating': { S: 'terminating' }, + ':updatedAt': { S: now.toISOString() }, + ':staleBefore': { S: staleBefore }, + ':expiresAt': { N: expiresAt(now, this.config.ttlSeconds) }, + }, + ReturnValues: 'ALL_OLD', + }), + ) + ).Attributes; + const previousState = previous?.[STATE_ATTRIBUTE]?.S; + if (previousState === undefined || !isRunnerLifecycleState(previousState)) { + throw new Error(`Runner state item '${this.config.scope}/${runnerStateId(runnerId)}' returned no prior state`); + } + return previousState; + } catch (error) { + if (error instanceof ConditionalCheckFailedException) { + return undefined; + } + throw error; + } + } + + async cancelTermination(runnerId: string, restoreState: 'provisioning' | 'active' | 'orphan'): Promise { + if (restoreState !== 'provisioning' && restoreState !== 'active' && restoreState !== 'orphan') { + throw new Error("Runner state field 'restoreState' must be 'provisioning', 'active', or 'orphan'"); + } + await this.transition(runnerId, ['terminating'], restoreState); + } + + async delete(runnerId: string): Promise { + validateNonEmptyString(runnerId, 'runnerId'); + await getDynamoDbClient().send( + new DeleteItemCommand({ + TableName: this.config.tableName, + Key: this.key(runnerId), + ConditionExpression: '#state = :terminating', + ExpressionAttributeNames: { + '#state': STATE_ATTRIBUTE, + }, + ExpressionAttributeValues: { + ':terminating': { S: 'terminating' }, + }, + }), + ); + } + + private async transition( + runnerId: string, + expectedStates: RunnerLifecycleState[], + state: RunnerLifecycleState, + activation: RunnerStateActivation = {}, + returnOldState = false, + ): Promise | undefined> { + validateNonEmptyString(runnerId, 'runnerId'); + const now = new Date(); + const expressionAttributeNames: Record = { + '#scope': SCOPE_ATTRIBUTE, + '#id': ID_ATTRIBUTE, + '#state': STATE_ATTRIBUTE, + '#updatedAt': UPDATED_AT_ATTRIBUTE, + '#expiresAt': EXPIRES_AT_ATTRIBUTE, + }; + const expressionAttributeValues: Record = { + ':state': { S: state }, + ':updatedAt': { S: now.toISOString() }, + }; + const updates = ['#state = :state', '#updatedAt = :updatedAt']; + const hasSafetyTtl = state === 'provisioning' || state === 'terminating'; + if (hasSafetyTtl) { + expressionAttributeValues[':expiresAt'] = { N: expiresAt(now, this.config.ttlSeconds) }; + updates.push('#expiresAt = :expiresAt'); + } + addActivationUpdates(updates, expressionAttributeNames, expressionAttributeValues, activation); + + const expectedStateValues = expectedStates.map((expectedState, index) => { + const placeholder = `:expectedState${index}`; + expressionAttributeValues[placeholder] = { S: expectedState }; + return placeholder; + }); + + const result = await getDynamoDbClient().send( + new UpdateItemCommand({ + TableName: this.config.tableName, + Key: this.key(runnerId), + UpdateExpression: `SET ${updates.join(', ')}${hasSafetyTtl ? '' : ' REMOVE #expiresAt'}`, + ConditionExpression: `attribute_exists(#scope) AND attribute_exists(#id) AND #state IN (${expectedStateValues.join(', ')})`, + ExpressionAttributeNames: expressionAttributeNames, + ExpressionAttributeValues: expressionAttributeValues, + ReturnValues: returnOldState ? 'ALL_OLD' : undefined, + }), + ); + return result.Attributes; + } + + private key(runnerId: string): Record { + return { + [SCOPE_ATTRIBUTE]: { S: this.config.scope }, + [ID_ATTRIBUTE]: { S: runnerStateId(runnerId) }, + }; + } +} + +function parseRunnerStateRecord(item: Record, scope: string): RunnerStateRecord { + const id = requiredStringAttribute(item, ID_ATTRIBUTE, scope); + const runnerType = requiredStringAttribute(item, RUNNER_TYPE_ATTRIBUTE, `${scope}/${id}`); + if (runnerType !== 'Org' && runnerType !== 'Repo') { + throw invalidItem(`${scope}/${id}`, RUNNER_TYPE_ATTRIBUTE); + } + + return { + runnerId: requiredStringAttribute(item, RUNNER_ID_ATTRIBUTE, `${scope}/${id}`), + computeProvider: requiredStringAttribute(item, COMPUTE_PROVIDER_ATTRIBUTE, `${scope}/${id}`), + computeResourceId: requiredStringAttribute(item, COMPUTE_RESOURCE_ID_ATTRIBUTE, `${scope}/${id}`), + runnerName: optionalStringAttribute(item, RUNNER_NAME_ATTRIBUTE, `${scope}/${id}`), + runnerLabels: optionalStringListAttribute(item, RUNNER_LABELS_ATTRIBUTE, `${scope}/${id}`), + githubRunnerId: optionalStringAttribute(item, GITHUB_RUNNER_ID_ATTRIBUTE, `${scope}/${id}`), + runnerOwner: requiredStringAttribute(item, RUNNER_OWNER_ATTRIBUTE, `${scope}/${id}`), + runnerType, + state: requiredLifecycleState(item, `${scope}/${id}`), + createdAt: requiredTimestampAttribute(item, CREATED_AT_ATTRIBUTE, `${scope}/${id}`), + updatedAt: requiredTimestampAttribute(item, UPDATED_AT_ATTRIBUTE, `${scope}/${id}`), + metadata: optionalMetadataAttribute(item, `${scope}/${id}`), + }; +} + +function requiredLifecycleState(item: Record, itemId: string): RunnerLifecycleState { + const state = requiredStringAttribute(item, STATE_ATTRIBUTE, itemId); + if (!isRunnerLifecycleState(state)) { + throw invalidItem(itemId, STATE_ATTRIBUTE); + } + return state; +} + +function isRunnerLifecycleState(value: string): value is RunnerLifecycleState { + return value === 'provisioning' || value === 'active' || value === 'orphan' || value === 'terminating'; +} + +function requiredStringAttribute(item: Record, name: string, itemId: string): string { + const value = item[name]?.S; + if (value === undefined || value.trim() === '') { + throw invalidItem(itemId, name); + } + return value; +} + +function optionalStringAttribute( + item: Record, + name: string, + itemId: string, +): string | undefined { + if (item[name] === undefined) { + return undefined; + } + return requiredStringAttribute(item, name, itemId); +} + +function optionalStringListAttribute( + item: Record, + name: string, + itemId: string, +): string[] | undefined { + const attribute = item[name]; + if (attribute === undefined) { + return undefined; + } + if (!attribute.L) { + throw invalidItem(itemId, name); + } + + return attribute.L.map((value) => { + if (value.S === undefined || value.S.trim() === '') { + throw invalidItem(itemId, name); + } + return value.S; + }); +} + +function requiredTimestampAttribute(item: Record, name: string, itemId: string): string { + const value = requiredStringAttribute(item, name, itemId); + try { + if (new Date(value).toISOString() !== value) { + throw invalidItem(itemId, name); + } + } catch { + throw invalidItem(itemId, name); + } + return value; +} + +function optionalMetadataAttribute( + item: Record, + itemId: string, +): RunnerConfigMetadata[] | undefined { + const metadata = item[METADATA_ATTRIBUTE]; + if (metadata === undefined) { + return undefined; + } + if (!metadata.L) { + throw invalidItem(itemId, METADATA_ATTRIBUTE); + } + + return metadata.L.map((entry) => { + if (!entry.M) { + throw invalidItem(itemId, METADATA_ATTRIBUTE); + } + return { + key: requiredStringAttribute(entry.M, 'key', itemId), + value: requiredMetadataValue(entry.M, itemId), + }; + }); +} + +function validateCreateRecord(record: CreateRunnerStateRecord): void { + validateNonEmptyString(record.runnerId, 'runnerId'); + validateNonEmptyString(record.computeProvider, 'computeProvider'); + validateNonEmptyString(record.computeResourceId, 'computeResourceId'); + validateOptionalString(record.runnerName, 'runnerName'); + validateOptionalStringList(record.runnerLabels, 'runnerLabels'); + validateNonEmptyString(record.runnerOwner, 'runnerOwner'); + validateRunnerType(record.runnerType); + for (const metadata of record.metadata ?? []) { + validateNonEmptyString(metadata.key, 'metadata.key'); + validateString(metadata.value, 'metadata.value'); + } +} + +function validateActivation(activation: RunnerStateActivation): void { + validateOptionalString(activation.runnerName, 'runnerName'); + validateOptionalStringList(activation.runnerLabels, 'runnerLabels'); + validateOptionalString(activation.githubRunnerId, 'githubRunnerId'); + for (const metadata of activation.metadata ?? []) { + validateNonEmptyString(metadata.key, 'metadata.key'); + validateString(metadata.value, 'metadata.value'); + } +} + +function validateRunnerType(value: RunnerType): void { + if (value !== 'Org' && value !== 'Repo') { + throw new Error("Runner state field 'runnerType' must be 'Org' or 'Repo'"); + } +} + +function validateOptionalString(value: string | undefined, name: string): void { + if (value !== undefined) { + validateNonEmptyString(value, name); + } +} + +function validateOptionalStringList(values: string[] | undefined, name: string): void { + for (const value of values ?? []) { + validateNonEmptyString(value, name); + } +} + +function validateNonEmptyString(value: string, name: string): void { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`Runner state field '${name}' must be a non-empty string`); + } +} + +function validateString(value: string, name: string): void { + if (typeof value !== 'string') { + throw new Error(`Runner state field '${name}' must be a string`); + } +} + +function requiredMetadataValue(item: Record, itemId: string): string { + const value = item.value?.S; + if (value === undefined) { + throw invalidItem(itemId, METADATA_ATTRIBUTE); + } + return value; +} + +function setOptionalString(item: Record, name: string, value: string | undefined): void { + if (value !== undefined) { + item[name] = { S: value }; + } +} + +function setOptionalStringList(item: Record, name: string, values: string[] | undefined): void { + if (values !== undefined) { + item[name] = { L: values.map((value) => ({ S: value })) }; + } +} + +function addActivationUpdates( + updates: string[], + names: Record, + values: Record, + activation: RunnerStateActivation, +): void { + addOptionalStringUpdate( + updates, + names, + values, + '#runnerName', + ':runnerName', + RUNNER_NAME_ATTRIBUTE, + activation.runnerName, + ); + addOptionalStringListUpdate( + updates, + names, + values, + '#runnerLabels', + ':runnerLabels', + RUNNER_LABELS_ATTRIBUTE, + activation.runnerLabels, + ); + addOptionalStringUpdate( + updates, + names, + values, + '#githubRunnerId', + ':githubRunnerId', + GITHUB_RUNNER_ID_ATTRIBUTE, + activation.githubRunnerId, + ); + if (activation.metadata !== undefined) { + names['#metadata'] = METADATA_ATTRIBUTE; + values[':metadata'] = { + L: activation.metadata.map(({ key, value }) => ({ M: { key: { S: key }, value: { S: value } } })), + }; + updates.push('#metadata = :metadata'); + } +} + +function addOptionalStringUpdate( + updates: string[], + names: Record, + values: Record, + namePlaceholder: string, + valuePlaceholder: string, + attributeName: string, + value: string | undefined, +): void { + if (value !== undefined) { + names[namePlaceholder] = attributeName; + values[valuePlaceholder] = { S: value }; + updates.push(`${namePlaceholder} = ${valuePlaceholder}`); + } +} + +function addOptionalStringListUpdate( + updates: string[], + names: Record, + values: Record, + namePlaceholder: string, + valuePlaceholder: string, + attributeName: string, + value: string[] | undefined, +): void { + if (value !== undefined) { + names[namePlaceholder] = attributeName; + values[valuePlaceholder] = { L: value.map((entry) => ({ S: entry })) }; + updates.push(`${namePlaceholder} = ${valuePlaceholder}`); + } +} + +function setMetadata(item: Record, metadata: RunnerConfigMetadata[] | undefined): void { + if (metadata && metadata.length > 0) { + item[METADATA_ATTRIBUTE] = { + L: metadata.map(({ key, value }) => ({ M: { key: { S: key }, value: { S: value } } })), + }; + } +} + +function expiresAt(now: Date, ttlSeconds: number): string { + return (Math.floor(now.getTime() / 1000) + ttlSeconds).toString(); +} + +function invalidItem(itemId: string, attribute: string): Error { + return new Error(`Runner state item '${itemId}' has an invalid '${attribute}' attribute`); +} diff --git a/lambdas/libs/storage-providers/package.json b/lambdas/libs/storage-providers/package.json index 330d60a3f6..1a8687ef32 100644 --- a/lambdas/libs/storage-providers/package.json +++ b/lambdas/libs/storage-providers/package.json @@ -20,6 +20,7 @@ "dependencies": { "@aws-github-runner/aws-powertools-util": "*", "@aws-github-runner/aws-ssm-util": "*", + "@aws-sdk/client-dynamodb": "^3.1009.0", "@aws-sdk/client-ssm": "^3.1009.0" }, "devDependencies": { diff --git a/lambdas/yarn.lock b/lambdas/yarn.lock index 45f8c78f6c..59d17f0f50 100644 --- a/lambdas/yarn.lock +++ b/lambdas/yarn.lock @@ -206,6 +206,7 @@ __metadata: dependencies: "@aws-github-runner/aws-powertools-util": "npm:*" "@aws-github-runner/aws-ssm-util": "npm:*" + "@aws-sdk/client-dynamodb": "npm:^3.1009.0" "@aws-sdk/client-ssm": "npm:^3.1009.0" aws-sdk-client-mock: "npm:^4.1.0" aws-sdk-client-mock-jest: "npm:^4.1.0" @@ -345,6 +346,24 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-dynamodb@npm:^3.1009.0": + version: 3.1132.0 + resolution: "@aws-sdk/client-dynamodb@npm:3.1132.0" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@aws-sdk/credential-provider-node": "npm:^3.972.83" + "@aws-sdk/dynamodb-codec": "npm:^3.973.45" + "@aws-sdk/middleware-endpoint-discovery": "npm:^3.972.30" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/fetch-http-handler": "npm:^5.7.2" + "@smithy/node-http-handler": "npm:^4.11.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/7be5746998cde524982ae88ebab0e7466e01eb9e980f2510a6e3d3694c5c90904ca6b060b8c453fd8e86c2fe2f130371db43b00014ecd8e4d30ee2b4c545f3a6 + languageName: node + linkType: hard + "@aws-sdk/client-ec2@npm:^3.1009.0": version: 3.1014.0 resolution: "@aws-sdk/client-ec2@npm:3.1014.0" @@ -623,6 +642,22 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/core@npm:^3.978.0": + version: 3.978.0 + resolution: "@aws-sdk/core@npm:3.978.0" + dependencies: + "@aws-sdk/types": "npm:^3.974.5" + "@aws-sdk/xml-builder": "npm:^3.972.40" + "@aws/lambda-invoke-store": "npm:^0.3.0" + "@smithy/core": "npm:^3.33.3" + "@smithy/signature-v4": "npm:^5.6.12" + "@smithy/types": "npm:^4.17.2" + bowser: "npm:^2.11.0" + tslib: "npm:^2.6.2" + checksum: 10c0/ac333ff04ce5c868eb6e5aeadeae7a02be302d95c3b6d9e753c50bc607f8fa56a26f9d96aad9866aba9c57ef8c20dec05910abac0246a8f6cf1c8c6e0f6237a9 + languageName: node + linkType: hard + "@aws-sdk/crc64-nvme@npm:^3.972.5": version: 3.972.5 resolution: "@aws-sdk/crc64-nvme@npm:3.972.5" @@ -646,6 +681,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-env@npm:^3.972.71": + version: 3.972.71 + resolution: "@aws-sdk/credential-provider-env@npm:3.972.71" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/0e7bfbb37e92c4d3e390c4e508d47fbb7ec25381a9dd0cb79c2e075a79ebe3ee173521c2f9b999b53f415a6a15ad7966c1e1451a997523caa3e1be185e7edee3 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-http@npm:^3.972.23": version: 3.972.23 resolution: "@aws-sdk/credential-provider-http@npm:3.972.23" @@ -664,6 +712,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-http@npm:^3.972.73": + version: 3.972.73 + resolution: "@aws-sdk/credential-provider-http@npm:3.972.73" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/fetch-http-handler": "npm:^5.7.2" + "@smithy/node-http-handler": "npm:^4.11.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/b9a8a595ed9186395fad43556638e4fb34f26395c69a619c9d406a0c803a104494622ee077c6d970f0bc15c8176a0676921b30a7e38ff093b88d0dc8d5d08f63 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-ini@npm:^3.972.23": version: 3.972.23 resolution: "@aws-sdk/credential-provider-ini@npm:3.972.23" @@ -686,6 +749,27 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-ini@npm:^3.973.16": + version: 3.973.16 + resolution: "@aws-sdk/credential-provider-ini@npm:3.973.16" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@aws-sdk/credential-provider-env": "npm:^3.972.71" + "@aws-sdk/credential-provider-http": "npm:^3.972.73" + "@aws-sdk/credential-provider-login": "npm:^3.972.78" + "@aws-sdk/credential-provider-process": "npm:^3.972.71" + "@aws-sdk/credential-provider-sso": "npm:^3.973.15" + "@aws-sdk/credential-provider-web-identity": "npm:^3.972.77" + "@aws-sdk/nested-clients": "npm:^3.997.45" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/credential-provider-imds": "npm:^4.4.16" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/f7acfae0c005fd9b1470a6b328719d9bf8d672c57dd22e9f57e158de3c3712ecac75190662a87e7c62b4201bfaa91a81f962772e8773789515304b2f9e26537a + languageName: node + linkType: hard + "@aws-sdk/credential-provider-login@npm:^3.972.23": version: 3.972.23 resolution: "@aws-sdk/credential-provider-login@npm:3.972.23" @@ -702,6 +786,20 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-login@npm:^3.972.78": + version: 3.972.78 + resolution: "@aws-sdk/credential-provider-login@npm:3.972.78" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@aws-sdk/nested-clients": "npm:^3.997.45" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/c86edd5c8cfe7e112cc9498867c6af3e12a73edea9e45ee95406d7ffc06baae42bf22cf30d00be4243a2eea6f192c13cd142669cca9a3dbcd367906018779b1b + languageName: node + linkType: hard + "@aws-sdk/credential-provider-node@npm:^3.972.24": version: 3.972.24 resolution: "@aws-sdk/credential-provider-node@npm:3.972.24" @@ -722,6 +820,25 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-node@npm:^3.972.83": + version: 3.972.83 + resolution: "@aws-sdk/credential-provider-node@npm:3.972.83" + dependencies: + "@aws-sdk/credential-provider-env": "npm:^3.972.71" + "@aws-sdk/credential-provider-http": "npm:^3.972.73" + "@aws-sdk/credential-provider-ini": "npm:^3.973.16" + "@aws-sdk/credential-provider-process": "npm:^3.972.71" + "@aws-sdk/credential-provider-sso": "npm:^3.973.15" + "@aws-sdk/credential-provider-web-identity": "npm:^3.972.77" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/credential-provider-imds": "npm:^4.4.16" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/50ad40d4771d3ba46c295bb504bea470fba73237e3e01f34646fd14141428d7e4d85adb73fe73f0a6e4d84b462002cad702521b7f3cea1e6a1e75711b198e22c + languageName: node + linkType: hard + "@aws-sdk/credential-provider-process@npm:^3.972.21": version: 3.972.21 resolution: "@aws-sdk/credential-provider-process@npm:3.972.21" @@ -736,6 +853,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-process@npm:^3.972.71": + version: 3.972.71 + resolution: "@aws-sdk/credential-provider-process@npm:3.972.71" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/a4d55be31ad01c2307ae6bb4b6b30b8be5690276e7c3bfc0ff194e8aa788ff81bde7f6c3641094a886dc6304ced38f03bbc72fce4598cfaa1a2952a267327cd7 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-sso@npm:^3.972.23": version: 3.972.23 resolution: "@aws-sdk/credential-provider-sso@npm:3.972.23" @@ -752,6 +882,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-sso@npm:^3.973.15": + version: 3.973.15 + resolution: "@aws-sdk/credential-provider-sso@npm:3.973.15" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@aws-sdk/nested-clients": "npm:^3.997.45" + "@aws-sdk/token-providers": "npm:3.1129.0" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/0dc16806de6000795b25a933bf940af9ed9a70e129c66685ce40ddb4c84f449cc8dacafcd10103e808c7b6c1aceb7a08a00c8d3646de37d369291c4eb37a7c46 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-web-identity@npm:^3.972.23": version: 3.972.23 resolution: "@aws-sdk/credential-provider-web-identity@npm:3.972.23" @@ -767,6 +912,42 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-web-identity@npm:^3.972.77": + version: 3.972.77 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.972.77" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@aws-sdk/nested-clients": "npm:^3.997.45" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/a2ed48d01eb850a0ee5e72287f02ff1402aa2eab8042b4e237fa636a58b566ee46cb1bf235ae6d61dbb6a6b4ebd44ce1d74f8aba71a250db27557746ce1d3021 + languageName: node + linkType: hard + +"@aws-sdk/dynamodb-codec@npm:^3.973.45": + version: 3.973.45 + resolution: "@aws-sdk/dynamodb-codec@npm:3.973.45" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/769d8c8ca6d32ead5e186ecffacaa175a81efae412339fae9fba55256431597635ff8c9e7ee31bc38ad0b43bd94ad951ff64523c68f3ed3e462407a996e321ce + languageName: node + linkType: hard + +"@aws-sdk/endpoint-cache@npm:^3.972.11": + version: 3.972.11 + resolution: "@aws-sdk/endpoint-cache@npm:3.972.11" + dependencies: + mnemonist: "npm:0.38.3" + tslib: "npm:^2.6.2" + checksum: 10c0/8f3c039fb2dd8e434cd2992bce2a840e6f6470be5ae6d71c762f6cb842149fb60d5566ca470319d5abcf90b115d2c1a0114ed3328afbfc0abed4cbcd52ad03a1 + languageName: node + linkType: hard + "@aws-sdk/lib-storage@npm:^3.1009.0": version: 3.1014.0 resolution: "@aws-sdk/lib-storage@npm:3.1014.0" @@ -799,6 +980,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-endpoint-discovery@npm:^3.972.30": + version: 3.972.30 + resolution: "@aws-sdk/middleware-endpoint-discovery@npm:3.972.30" + dependencies: + "@aws-sdk/endpoint-cache": "npm:^3.972.11" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/5655eccf7293f481f45a57359a7d7737ff7345cae3602ae5cb669653f7bc1503f61ce837f11d8bfe399726ae189503cfdc2c21d198816f5acc2d238b7b3476e2 + languageName: node + linkType: hard + "@aws-sdk/middleware-expect-continue@npm:^3.972.8": version: 3.972.8 resolution: "@aws-sdk/middleware-expect-continue@npm:3.972.8" @@ -1005,6 +1199,22 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/nested-clients@npm:^3.997.45": + version: 3.997.45 + resolution: "@aws-sdk/nested-clients@npm:3.997.45" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@aws-sdk/signature-v4-multi-region": "npm:^3.996.46" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/fetch-http-handler": "npm:^5.7.2" + "@smithy/node-http-handler": "npm:^4.11.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/ef65f970b8e0aa55bf7a837fefbf3a042f247dc4fd7291bc0d64c5115c825e2785d8684a62e97ea7f519ed9a4251b922fbe84e6278dd77d45f1d7cf740dd525d + languageName: node + linkType: hard + "@aws-sdk/region-config-resolver@npm:^3.972.9": version: 3.972.9 resolution: "@aws-sdk/region-config-resolver@npm:3.972.9" @@ -1032,6 +1242,18 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/signature-v4-multi-region@npm:^3.996.46": + version: 3.996.46 + resolution: "@aws-sdk/signature-v4-multi-region@npm:3.996.46" + dependencies: + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/signature-v4": "npm:^5.6.12" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/069dfb7a95663cad2e0aec1d87df8a800abad33cb49dfbe9412dad2d63ab6350328c6165166b12d50e609d8dbe5a5536aa6b1101be4d91584fbca76b2fea4d00 + languageName: node + linkType: hard + "@aws-sdk/token-providers@npm:3.1014.0": version: 3.1014.0 resolution: "@aws-sdk/token-providers@npm:3.1014.0" @@ -1047,6 +1269,20 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/token-providers@npm:3.1129.0": + version: 3.1129.0 + resolution: "@aws-sdk/token-providers@npm:3.1129.0" + dependencies: + "@aws-sdk/core": "npm:^3.978.0" + "@aws-sdk/nested-clients": "npm:^3.997.45" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/bbdfaa58f2d7e7f2aa11d826d604b3464896a1149d0ab83757c1f59ec1b267a2887626a1aa8ad2859f2871e442c0a46d28215d490c8a604e54ea55216d9b7621 + languageName: node + linkType: hard + "@aws-sdk/types@npm:^3.222.0, @aws-sdk/types@npm:^3.4.1, @aws-sdk/types@npm:^3.973.6": version: 3.973.6 resolution: "@aws-sdk/types@npm:3.973.6" @@ -1057,6 +1293,16 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/types@npm:^3.974.5": + version: 3.974.5 + resolution: "@aws-sdk/types@npm:3.974.5" + dependencies: + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/803aaaa1c0675dcb564803993f3c47d96302fad461af8af80e75afc40c72228ebf669593c18e44ca09fc5acf0d1bd25966261de07844d8f11ad82aa2650252d0 + languageName: node + linkType: hard + "@aws-sdk/util-arn-parser@npm:^3.972.3": version: 3.972.3 resolution: "@aws-sdk/util-arn-parser@npm:3.972.3" @@ -1142,6 +1388,16 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/xml-builder@npm:^3.972.40": + version: 3.972.40 + resolution: "@aws-sdk/xml-builder@npm:3.972.40" + dependencies: + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/5b06fa0466b5ddb0e33138dc16f6a30e11e52fc5ea71f3ed72af7b24621d29c9e8103df3eed9c4f14cef50f4d345dd9e7021242a8c155a67869ee4ce79982bb4 + languageName: node + linkType: hard + "@aws/lambda-invoke-store@npm:0.2.3, @aws/lambda-invoke-store@npm:^0.2.2": version: 0.2.3 resolution: "@aws/lambda-invoke-store@npm:0.2.3" @@ -1149,6 +1405,13 @@ __metadata: languageName: node linkType: hard +"@aws/lambda-invoke-store@npm:^0.3.0": + version: 0.3.0 + resolution: "@aws/lambda-invoke-store@npm:0.3.0" + checksum: 10c0/b4a2e6b3b5397bc606053e64270d26dc5c886336f88a98cad587b1592eec17058f8fb172f1827a9f0e591f3595cf8f01575c8c9b36cde38c06456f8a65204046 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": version: 7.29.0 resolution: "@babel/code-frame@npm:7.29.0" @@ -4565,6 +4828,16 @@ __metadata: languageName: node linkType: hard +"@smithy/core@npm:^3.33.2, @smithy/core@npm:^3.33.3": + version: 3.34.1 + resolution: "@smithy/core@npm:3.34.1" + dependencies: + "@smithy/types": "npm:^4.18.0" + tslib: "npm:^2.6.2" + checksum: 10c0/66f846e884e6a3cd4df102f1d6c96b20a04390a0ef4051dd172a83e302b3f3cf7a8d735ac0fd215e878117bfb77a95e7bdc81521c3d3bf0bce9ea1c6d9ff8b24 + languageName: node + linkType: hard + "@smithy/credential-provider-imds@npm:^4.2.12": version: 4.2.12 resolution: "@smithy/credential-provider-imds@npm:4.2.12" @@ -4578,6 +4851,17 @@ __metadata: languageName: node linkType: hard +"@smithy/credential-provider-imds@npm:^4.4.16": + version: 4.5.2 + resolution: "@smithy/credential-provider-imds@npm:4.5.2" + dependencies: + "@smithy/core": "npm:^3.33.2" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/d5481a7797a1f485849d92f6c188cfb9411736ae2469e27394b863e107dd1024266baa8e3a62cfc3f75b3abfd84b3c389955a0e96b64c4ad90462c70bbe66ab0 + languageName: node + linkType: hard + "@smithy/eventstream-codec@npm:^4.2.12": version: 4.2.12 resolution: "@smithy/eventstream-codec@npm:4.2.12" @@ -4646,6 +4930,17 @@ __metadata: languageName: node linkType: hard +"@smithy/fetch-http-handler@npm:^5.7.2": + version: 5.8.0 + resolution: "@smithy/fetch-http-handler@npm:5.8.0" + dependencies: + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.18.0" + tslib: "npm:^2.6.2" + checksum: 10c0/8035961bad01fd80de32caf2bb9b035cf6e102c8586790017660fa4440ba0e9ee126942b58f2ca456e94972e90c25d863378939c252b903d2741c7b05f202279 + languageName: node + linkType: hard + "@smithy/hash-blob-browser@npm:^4.2.13": version: 4.2.13 resolution: "@smithy/hash-blob-browser@npm:4.2.13" @@ -4798,6 +5093,17 @@ __metadata: languageName: node linkType: hard +"@smithy/node-http-handler@npm:^4.11.3": + version: 4.12.1 + resolution: "@smithy/node-http-handler@npm:4.12.1" + dependencies: + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.18.0" + tslib: "npm:^2.6.2" + checksum: 10c0/a657259f8ebbff531cad854e9e12ff3f98b84c095965f4f9a6e8a3e1af4f5bbbb1bc8a7228df220663872c2eef74b5403d148dbe6be1edee06a3baa93cbeb5dc + languageName: node + linkType: hard + "@smithy/node-http-handler@npm:^4.5.0": version: 4.5.0 resolution: "@smithy/node-http-handler@npm:4.5.0" @@ -4896,6 +5202,17 @@ __metadata: languageName: node linkType: hard +"@smithy/signature-v4@npm:^5.6.12": + version: 5.7.3 + resolution: "@smithy/signature-v4@npm:5.7.3" + dependencies: + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/6976142320c7c112ee817f5329d0adc45c1ec101a095a70e4afcb7e3fa9f18cd4f544dcbb481e1932fb877197ec9e1037054bacda422cccfd8de7eed55905319 + languageName: node + linkType: hard + "@smithy/smithy-client@npm:^4.12.7": version: 4.12.7 resolution: "@smithy/smithy-client@npm:4.12.7" @@ -4929,6 +5246,15 @@ __metadata: languageName: node linkType: hard +"@smithy/types@npm:^4.17.2, @smithy/types@npm:^4.18.0": + version: 4.18.0 + resolution: "@smithy/types@npm:4.18.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/f948eaf2c6004ce919a5a203615da4d2d4923465df764c2f6ab982fcacde10c81e1fd23c40f983387459ffdad056f8e827ebecaa776a4331ed4f6431ad8bdd34 + languageName: node + linkType: hard + "@smithy/url-parser@npm:^4.2.12": version: 4.2.12 resolution: "@smithy/url-parser@npm:4.2.12" @@ -9105,6 +9431,15 @@ __metadata: languageName: node linkType: hard +"mnemonist@npm:0.38.3": + version: 0.38.3 + resolution: "mnemonist@npm:0.38.3" + dependencies: + obliterator: "npm:^1.6.1" + checksum: 10c0/064aa1ee1a89fce2754423b3617c598fd65bc34311eb3c01dc063976f6b819b073bd23532415cf8c92240157b4c8fbb7ec5d79d717f2bd4fcd95d8131cb23acb + languageName: node + linkType: hard + "moment-timezone@npm:^0.6.0": version: 0.6.0 resolution: "moment-timezone@npm:0.6.0" @@ -9516,6 +9851,13 @@ __metadata: languageName: node linkType: hard +"obliterator@npm:^1.6.1": + version: 1.6.1 + resolution: "obliterator@npm:1.6.1" + checksum: 10c0/5fad57319aae0ef6e34efa640541d41c2dd9790a7ab808f17dcb66c83a81333963fc2dfcfa6e1b62158e5cef6291cdcf15c503ad6c3de54b2227dd4c3d7e1b55 + languageName: node + linkType: hard + "obug@npm:^2.1.1": version: 2.1.1 resolution: "obug@npm:2.1.1" diff --git a/modules/storage-providers/aws/dynamodb/README.md b/modules/storage-providers/aws/dynamodb/README.md new file mode 100644 index 0000000000..27d1daed3b --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/README.md @@ -0,0 +1,53 @@ + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.5.6 | +| [aws](#requirement\_aws) | >= 6.33 | + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | +| [terraform](#provider\_terraform) | n/a | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [aws_dynamodb_table.config](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/dynamodb_table) | resource | +| [aws_dynamodb_table.runner_state](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/dynamodb_table) | resource | +| [aws_dynamodb_table_item.github_app_credentials](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/dynamodb_table_item) | resource | +| [aws_dynamodb_table_item.github_webhook_secret](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/dynamodb_table_item) | resource | +| [aws_dynamodb_table_item.runner_config](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/dynamodb_table_item) | resource | +| [aws_dynamodb_table_item.runner_matcher_config](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/dynamodb_table_item) | resource | +| [terraform_data.config_version](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [config](#input\_config) | Settings for the shared durable configuration table and ephemeral runner-state table.

- `config.kms_key_arn`: Optional customer-managed KMS key ARN for durable configuration encryption. Null uses the AWS-owned DynamoDB key.
- `config.point_in_time_recovery_enabled`: Enables point-in-time recovery for durable configuration.
- `config.deletion_protection_enabled`: Enables deletion protection for the durable table.
- `config.tags`: Tags applied after the shared tag map.
- `runner_state.kms_key_arn`: Optional customer-managed KMS key ARN for runner-state encryption. Null uses the AWS-owned DynamoDB key.
- `runner_state.point_in_time_recovery_enabled`: Enables point-in-time recovery for ephemeral runner state.
- `runner_state.deletion_protection_enabled`: Enables deletion protection for the runner-state table.
- `runner_state.tags`: Tags applied after the shared tag map. |
object({
config = object({
kms_key_arn = optional(string, null)
point_in_time_recovery_enabled = optional(bool, true)
deletion_protection_enabled = optional(bool, false)
tags = optional(map(string), {})
})
runner_state = object({
kms_key_arn = optional(string, null)
point_in_time_recovery_enabled = optional(bool, false)
deletion_protection_enabled = optional(bool, false)
tags = optional(map(string), {})
})
})
| n/a | yes | +| [entry\_ids](#input\_entry\_ids) | Runner-entry identifiers used to build entry-scoped Lambda capabilities. | `set(string)` | n/a | yes | +| [entry\_records](#input\_entry\_records) | Resolved durable runner bootstrap configuration keyed by runner-entry identifier. |
map(object({
run_as = string
agent_mode = string
disable_default_labels = bool
enable_jit_config = bool
}))
| n/a | yes | +| [global\_records](#input\_global\_records) | Terraform-managed values stored under the shared global scope. |
object({
github_app_credentials = string
github_webhook_secret = string
runner_matcher_config = string
})
| n/a | yes | +| [prefix](#input\_prefix) | Multi-runner prefix used to name the two shared DynamoDB tables. | `string` | n/a | yes | +| [runner\_config\_access\_scope\_prefixes](#input\_runner\_config\_access\_scope\_prefixes) | Per-entry compute-resource scope prefixes used to constrain one-time runner-config writes. | `map(string)` | n/a | yes | +| [runner\_config\_ttl\_seconds](#input\_runner\_config\_ttl\_seconds) | TTL in seconds for one-time registration and JIT configuration records. | `number` | n/a | yes | +| [runner\_state\_ttl\_seconds](#input\_runner\_state\_ttl\_seconds) | Safety TTL in seconds applied only while lifecycle records are provisioning or terminating; active and orphan inventory has no expiry. | `number` | n/a | yes | +| [tags](#input\_tags) | Base tags added to both shared DynamoDB tables. Table-specific tags override matching keys. | `map(string)` | `{}` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [capabilities](#output\_capabilities) | Opaque environment and least-privilege IAM additions consumed by the shared webhook and each runner entry's control-plane functions. | +| [config\_table](#output\_config\_table) | Shared durable configuration table. Global and runner-entry records are separated by the `scope` partition key. | +| [runner\_state\_table](#output\_runner\_state\_table) | Shared TTL-backed table containing ephemeral runner configuration and provider-neutral runner lifecycle records. | + \ No newline at end of file diff --git a/modules/storage-providers/aws/dynamodb/capabilities.tf b/modules/storage-providers/aws/dynamodb/capabilities.tf new file mode 100644 index 0000000000..bed090425d --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/capabilities.tf @@ -0,0 +1,283 @@ +locals { + config_environment_variables = { + RUNNER_CONFIG_STORAGE_PROVIDER = "aws_dynamodb" + RUNNER_CONFIG_STORAGE_VERSION = terraform_data.config_version.id + RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME = aws_dynamodb_table.config.name + } + + matcher_environment_variables = merge(local.config_environment_variables, { + RUNNER_MATCHER_CONFIG_VERSION = nonsensitive(sha256(var.global_records.runner_matcher_config)) + }) + + runner_state_environment_variables = { + RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME = aws_dynamodb_table.runner_state.name + RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TTL_SECONDS = tostring(var.runner_state_ttl_seconds) + } + + runner_config_environment_variables = { + RUNNER_CONFIG_DYNAMODB_TTL_SECONDS = tostring(var.runner_config_ttl_seconds) + } + + global_scopes = { + github_app = "global#github-app" + webhook = "global#webhook" + matcher = "global#matcher" + } + + entry_scopes = { + for entry_id in var.entry_ids : entry_id => { + bootstrap = "entry#${entry_id}#bootstrap" + runner_group = "entry#${entry_id}#runner-group" + runner_state = "entry#${entry_id}#runner-state" + } + } + + entry_environment_variables = { + for entry_id, scopes in local.entry_scopes : entry_id => merge(local.config_environment_variables, { + RUNNER_CONFIG_DYNAMODB_ENTRY_ID = entry_id + }) + } + + scale_up_environment_variables = { + for entry_id in var.entry_ids : entry_id => merge( + local.entry_environment_variables[entry_id], + local.runner_state_environment_variables, + local.runner_config_environment_variables, + ) + } + + scale_down_environment_variables = { + for entry_id in var.entry_ids : entry_id => merge( + local.entry_environment_variables[entry_id], + local.runner_state_environment_variables, + ) + } + + github_app_read_statement = { + Effect = "Allow" + Action = ["dynamodb:GetItem"] + Resource = [aws_dynamodb_table.config.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [local.global_scopes.github_app] + } + } + } + + direct_webhook_read_statement = { + Effect = "Allow" + Action = ["dynamodb:GetItem"] + Resource = [aws_dynamodb_table.config.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [local.global_scopes.webhook, local.global_scopes.matcher] + } + } + } + + eventbridge_webhook_read_statement = { + Effect = "Allow" + Action = ["dynamodb:GetItem"] + Resource = [aws_dynamodb_table.config.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [local.global_scopes.webhook] + } + } + } + + dispatcher_read_statement = { + Effect = "Allow" + Action = ["dynamodb:GetItem"] + Resource = [aws_dynamodb_table.config.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [local.global_scopes.matcher] + } + } + } + + config_kms_decrypt_statements = var.config.config.kms_key_arn == null ? [] : [{ + Effect = "Allow" + Action = ["kms:Decrypt"] + Resource = [var.config.config.kms_key_arn] + }] + + runner_state_kms_decrypt_statements = var.config.runner_state.kms_key_arn == null ? [] : [{ + Effect = "Allow" + Action = ["kms:Decrypt"] + Resource = [var.config.runner_state.kms_key_arn] + }] + + direct_webhook_iam_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = concat( + [local.direct_webhook_read_statement], + local.config_kms_decrypt_statements, + ) + }) + + eventbridge_webhook_iam_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = concat( + [local.eventbridge_webhook_read_statement], + local.config_kms_decrypt_statements, + ) + }) + + dispatcher_iam_policy_json = jsonencode({ + Version = "2012-10-17" + Statement = concat( + [local.dispatcher_read_statement], + local.config_kms_decrypt_statements, + ) + }) + + entry_runner_group_statements = { + for entry_id, scopes in local.entry_scopes : entry_id => { + Effect = "Allow" + Action = ["dynamodb:GetItem", "dynamodb:PutItem"] + Resource = [aws_dynamodb_table.config.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [scopes.runner_group] + } + } + } + } + + runner_config_write_statements = { + for entry_id, scopes in local.entry_scopes : entry_id => { + Effect = "Allow" + Action = ["dynamodb:PutItem"] + Resource = [aws_dynamodb_table.runner_state.arn] + Condition = { + "ForAllValues:StringLike" = { + "dynamodb:LeadingKeys" = ["${lookup(var.runner_config_access_scope_prefixes, entry_id, "__missing_runner_config_access_scope__")}*"] + } + } + } + } + + runner_state_write_statements = { + for entry_id, scopes in local.entry_scopes : entry_id => { + Effect = "Allow" + Action = [ + "dynamodb:PutItem", + "dynamodb:Query", + "dynamodb:UpdateItem", + ] + Resource = [aws_dynamodb_table.runner_state.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [scopes.runner_state] + } + } + } + } + + runner_state_reconcile_statements = { + for entry_id, scopes in local.entry_scopes : entry_id => { + Effect = "Allow" + Action = [ + "dynamodb:DeleteItem", + "dynamodb:Query", + "dynamodb:UpdateItem", + ] + Resource = [aws_dynamodb_table.runner_state.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [scopes.runner_state] + } + } + } + } + + scale_up_iam_policy_json = { + for entry_id in var.entry_ids : entry_id => jsonencode({ + Version = "2012-10-17" + Statement = concat( + [ + local.github_app_read_statement, + local.entry_runner_group_statements[entry_id], + local.runner_config_write_statements[entry_id], + local.runner_state_write_statements[entry_id], + ], + local.config_kms_decrypt_statements, + local.runner_state_kms_decrypt_statements, + ) + }) + } + + scale_down_iam_policy_json = { + for entry_id in var.entry_ids : entry_id => jsonencode({ + Version = "2012-10-17" + Statement = concat( + [local.github_app_read_statement, local.runner_state_reconcile_statements[entry_id]], + local.config_kms_decrypt_statements, + local.runner_state_kms_decrypt_statements, + ) + }) + } + + pool_iam_policy_json = { + for entry_id in var.entry_ids : entry_id => jsonencode({ + Version = "2012-10-17" + Statement = concat( + [ + local.github_app_read_statement, + local.entry_runner_group_statements[entry_id], + local.runner_config_write_statements[entry_id], + local.runner_state_write_statements[entry_id], + ], + local.config_kms_decrypt_statements, + local.runner_state_kms_decrypt_statements, + ) + }) + } + + job_retry_iam_policy_json = { + for entry_id in var.entry_ids : entry_id => jsonencode({ + Version = "2012-10-17" + Statement = concat( + [local.github_app_read_statement], + local.config_kms_decrypt_statements, + ) + }) + } + + runner_iam_policy_json = { + for entry_id, scopes in local.entry_scopes : entry_id => jsonencode({ + Version = "2012-10-17" + Statement = concat( + [ + { + Effect = "Allow" + Action = ["dynamodb:GetItem"] + Resource = [aws_dynamodb_table.config.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = [scopes.bootstrap] + } + } + }, + { + Effect = "Allow" + Action = [ + "dynamodb:DeleteItem", + "dynamodb:GetItem", + ] + Resource = [aws_dynamodb_table.runner_state.arn] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:LeadingKeys" = ["$${ec2:SourceInstanceARN}"] + } + } + }, + ], + local.config_kms_decrypt_statements, + local.runner_state_kms_decrypt_statements, + ) + }) + } +} diff --git a/modules/storage-providers/aws/dynamodb/config-version.tf b/modules/storage-providers/aws/dynamodb/config-version.tf new file mode 100644 index 0000000000..a0bd45605c --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/config-version.tf @@ -0,0 +1,23 @@ +resource "terraform_data" "config_version" { + triggers_replace = sensitive({ + global_records = sha256(jsonencode(var.global_records)) + entry_records = sha256(jsonencode(var.entry_records)) + }) + + lifecycle { + precondition { + condition = toset(keys(var.runner_config_access_scope_prefixes)) == var.entry_ids && alltrue([for prefix in values(var.runner_config_access_scope_prefixes) : trimspace(prefix) != ""]) + error_message = "runner_config_access_scope_prefixes must contain one non-empty prefix for every entry_id." + } + + precondition { + condition = var.runner_state_ttl_seconds > var.runner_config_ttl_seconds && floor(var.runner_state_ttl_seconds) == var.runner_state_ttl_seconds + error_message = "runner_state_ttl_seconds must be an integer greater than runner_config_ttl_seconds." + } + + precondition { + condition = toset(keys(var.entry_records)) == var.entry_ids + error_message = "entry_records must contain exactly one durable bootstrap record for every entry_id." + } + } +} diff --git a/modules/storage-providers/aws/dynamodb/items.tf b/modules/storage-providers/aws/dynamodb/items.tf new file mode 100644 index 0000000000..bc34f70e19 --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/items.tf @@ -0,0 +1,56 @@ +resource "aws_dynamodb_table_item" "github_app_credentials" { + table_name = aws_dynamodb_table.config.name + hash_key = aws_dynamodb_table.config.hash_key + range_key = aws_dynamodb_table.config.range_key + + item = jsonencode({ + scope = { S = local.global_scopes.github_app } + id = { S = "github-app-credentials" } + value = { S = var.global_records.github_app_credentials } + }) +} + +resource "aws_dynamodb_table_item" "github_webhook_secret" { + table_name = aws_dynamodb_table.config.name + hash_key = aws_dynamodb_table.config.hash_key + range_key = aws_dynamodb_table.config.range_key + + item = jsonencode({ + scope = { S = local.global_scopes.webhook } + id = { S = "github-webhook-secret" } + value = { S = var.global_records.github_webhook_secret } + }) +} + +resource "aws_dynamodb_table_item" "runner_matcher_config" { + table_name = aws_dynamodb_table.config.name + hash_key = aws_dynamodb_table.config.hash_key + range_key = aws_dynamodb_table.config.range_key + + item = jsonencode({ + scope = { S = local.global_scopes.matcher } + id = { S = "runner-matcher-config" } + value = { S = var.global_records.runner_matcher_config } + }) +} + +resource "aws_dynamodb_table_item" "runner_config" { + for_each = var.entry_records + + table_name = aws_dynamodb_table.config.name + hash_key = aws_dynamodb_table.config.hash_key + range_key = aws_dynamodb_table.config.range_key + + item = jsonencode({ + scope = { S = local.entry_scopes[each.key].bootstrap } + id = { S = "runner-config" } + value = { S = jsonencode(merge(each.value, { + runner_config_storage = { + provider = "aws_dynamodb" + table_name = aws_dynamodb_table.runner_state.name + access_scope = "compute-resource" + id = "config" + } + })) } + }) +} diff --git a/modules/storage-providers/aws/dynamodb/outputs.tf b/modules/storage-providers/aws/dynamodb/outputs.tf new file mode 100644 index 0000000000..4c337b212d --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/outputs.tf @@ -0,0 +1,71 @@ +output "config_table" { + description = "Shared durable configuration table. Global and runner-entry records are separated by the `scope` partition key." + value = { + arn = aws_dynamodb_table.config.arn + name = aws_dynamodb_table.config.name + } +} + +output "runner_state_table" { + description = "Shared TTL-backed table containing ephemeral runner configuration and provider-neutral runner lifecycle records." + value = { + arn = aws_dynamodb_table.runner_state.arn + name = aws_dynamodb_table.runner_state.name + ttl_attribute_name = "expires_at" + } +} + +output "capabilities" { + description = "Opaque environment and least-privilege IAM additions consumed by the shared webhook and each runner entry's control-plane functions." + depends_on = [ + aws_dynamodb_table_item.github_app_credentials, + aws_dynamodb_table_item.github_webhook_secret, + aws_dynamodb_table_item.runner_matcher_config, + aws_dynamodb_table_item.runner_config, + terraform_data.config_version, + ] + value = { + webhook = { + direct = { + environment_variables = tomap(local.matcher_environment_variables) + iam_policy_json = local.direct_webhook_iam_policy_json + } + eventbridge = { + webhook = { + environment_variables = tomap(local.config_environment_variables) + iam_policy_json = local.eventbridge_webhook_iam_policy_json + } + dispatcher = { + environment_variables = tomap(local.matcher_environment_variables) + iam_policy_json = local.dispatcher_iam_policy_json + } + } + } + entries = { + for entry_id in var.entry_ids : entry_id => { + scale_up = { + environment_variables = tomap(local.scale_up_environment_variables[entry_id]) + iam_policy_json = local.scale_up_iam_policy_json[entry_id] + } + scale_down = { + environment_variables = tomap(local.scale_down_environment_variables[entry_id]) + iam_policy_json = local.scale_down_iam_policy_json[entry_id] + } + pool = { + environment_variables = tomap(local.scale_up_environment_variables[entry_id]) + iam_policy_json = local.pool_iam_policy_json[entry_id] + } + job_retry = { + environment_variables = tomap(local.config_environment_variables) + iam_policy_json = local.job_retry_iam_policy_json[entry_id] + } + runner = { + config_table_name = aws_dynamodb_table.config.name + runner_state_table_name = aws_dynamodb_table.runner_state.name + scope = local.entry_scopes[entry_id].bootstrap + iam_policy_json = local.runner_iam_policy_json[entry_id] + } + } + } + } +} diff --git a/modules/storage-providers/aws/dynamodb/tables.tf b/modules/storage-providers/aws/dynamodb/tables.tf new file mode 100644 index 0000000000..12cd94867c --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/tables.tf @@ -0,0 +1,62 @@ +resource "aws_dynamodb_table" "config" { + name = "${var.prefix}-config" + billing_mode = "PAY_PER_REQUEST" + hash_key = "scope" + range_key = "id" + + attribute { + name = "scope" + type = "S" + } + + attribute { + name = "id" + type = "S" + } + + point_in_time_recovery { + enabled = var.config.config.point_in_time_recovery_enabled + } + + server_side_encryption { + enabled = true + kms_key_arn = var.config.config.kms_key_arn + } + + deletion_protection_enabled = var.config.config.deletion_protection_enabled + tags = merge(var.tags, var.config.config.tags) +} + +resource "aws_dynamodb_table" "runner_state" { + name = "${var.prefix}-runner-state" + billing_mode = "PAY_PER_REQUEST" + hash_key = "scope" + range_key = "id" + + attribute { + name = "scope" + type = "S" + } + + attribute { + name = "id" + type = "S" + } + + ttl { + attribute_name = "expires_at" + enabled = true + } + + point_in_time_recovery { + enabled = var.config.runner_state.point_in_time_recovery_enabled + } + + server_side_encryption { + enabled = true + kms_key_arn = var.config.runner_state.kms_key_arn + } + + deletion_protection_enabled = var.config.runner_state.deletion_protection_enabled + tags = merge(var.tags, var.config.runner_state.tags) +} diff --git a/modules/storage-providers/aws/dynamodb/variables.tf b/modules/storage-providers/aws/dynamodb/variables.tf new file mode 100644 index 0000000000..dc4e90badc --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/variables.tf @@ -0,0 +1,84 @@ +variable "prefix" { + description = "Multi-runner prefix used to name the two shared DynamoDB tables." + type = string +} + +variable "tags" { + description = "Base tags added to both shared DynamoDB tables. Table-specific tags override matching keys." + type = map(string) + default = {} +} + +variable "entry_ids" { + description = "Runner-entry identifiers used to build entry-scoped Lambda capabilities." + type = set(string) +} + +variable "runner_config_access_scope_prefixes" { + description = "Per-entry compute-resource scope prefixes used to constrain one-time runner-config writes." + type = map(string) +} + +variable "runner_config_ttl_seconds" { + description = "TTL in seconds for one-time registration and JIT configuration records." + type = number + + validation { + condition = var.runner_config_ttl_seconds > 0 && floor(var.runner_config_ttl_seconds) == var.runner_config_ttl_seconds + error_message = "runner_config_ttl_seconds must be a positive integer." + } +} + +variable "runner_state_ttl_seconds" { + description = "Safety TTL in seconds applied only while lifecycle records are provisioning or terminating; active and orphan inventory has no expiry." + type = number +} + +variable "global_records" { + description = "Terraform-managed values stored under the shared global scope." + type = object({ + github_app_credentials = string + github_webhook_secret = string + runner_matcher_config = string + }) + sensitive = true +} + +variable "entry_records" { + description = "Resolved durable runner bootstrap configuration keyed by runner-entry identifier." + type = map(object({ + run_as = string + agent_mode = string + disable_default_labels = bool + enable_jit_config = bool + })) +} + +variable "config" { + description = <<-EOT + Settings for the shared durable configuration table and ephemeral runner-state table. + + - `config.kms_key_arn`: Optional customer-managed KMS key ARN for durable configuration encryption. Null uses the AWS-owned DynamoDB key. + - `config.point_in_time_recovery_enabled`: Enables point-in-time recovery for durable configuration. + - `config.deletion_protection_enabled`: Enables deletion protection for the durable table. + - `config.tags`: Tags applied after the shared tag map. + - `runner_state.kms_key_arn`: Optional customer-managed KMS key ARN for runner-state encryption. Null uses the AWS-owned DynamoDB key. + - `runner_state.point_in_time_recovery_enabled`: Enables point-in-time recovery for ephemeral runner state. + - `runner_state.deletion_protection_enabled`: Enables deletion protection for the runner-state table. + - `runner_state.tags`: Tags applied after the shared tag map. + EOT + type = object({ + config = object({ + kms_key_arn = optional(string, null) + point_in_time_recovery_enabled = optional(bool, true) + deletion_protection_enabled = optional(bool, false) + tags = optional(map(string), {}) + }) + runner_state = object({ + kms_key_arn = optional(string, null) + point_in_time_recovery_enabled = optional(bool, false) + deletion_protection_enabled = optional(bool, false) + tags = optional(map(string), {}) + }) + }) +} diff --git a/modules/storage-providers/aws/dynamodb/versions.tf b/modules/storage-providers/aws/dynamodb/versions.tf new file mode 100644 index 0000000000..0bedc91fd5 --- /dev/null +++ b/modules/storage-providers/aws/dynamodb/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.6" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.33" + } + } +}