first commit

This commit is contained in:
Zakaria
2026-09-22 16:13:21 -04:00
commit 7e49be27f1
297 changed files with 63162 additions and 0 deletions
@@ -0,0 +1,189 @@
import {describe, expect, test} from 'bun:test'
import {
CreateDeploymentCommand,
CreateResourceCommand,
CreateRestApiCommand,
DeleteRestApiCommand,
GetRestApiCommand,
GetRestApisCommand,
GetResourcesCommand,
PutIntegrationCommand,
PutMethodCommand,
type APIGatewayClient,
} from '@aws-sdk/client-api-gateway'
import {AddPermissionCommand, GetFunctionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
import {AwsApiGatewayAdapter} from './AwsApiGatewayAdapter'
function fakeClient(handler: (command: unknown) => unknown): APIGatewayClient {
return {
send: async (command: unknown) => handler(command),
} as unknown as APIGatewayClient
}
function fakeLambda(handler: (command: unknown) => unknown): LambdaClient {
return {
send: async (command: unknown) => handler(command),
} as unknown as LambdaClient
}
const orders = {
id: 'a1b2c3',
name: 'orders-api',
description: 'Orders REST API',
createdDate: new Date('2026-01-02T03:04:05.000Z'),
version: '1.0',
endpointConfiguration: {types: ['REGIONAL']},
rootResourceId: 'root123',
}
describe('AwsApiGatewayAdapter', () => {
test('lists every page and maps REST APIs to normalized resources', async () => {
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
if (!(command instanceof GetRestApisCommand)) throw new Error('Unexpected command')
if (!command.input.position) return {items: [orders], position: 'next'}
return {items: [{...orders, id: 'd4e5f6', name: 'accounts-api'}]}
}))
const result = await adapter.list()
expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({
id: 'a1b2c3',
name: 'orders-api',
cloud: 'aws',
service: 'apigateway',
type: 'rest-api',
region: null,
createdAt: '2026-01-02T03:04:05.000Z',
})
expect(result[0].metadata).toMatchObject({
apigatewayService: 'rest',
description: 'Orders REST API',
rootResourceId: 'root123',
})
})
test('filters APIs by search term', async () => {
const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({
items: [orders, {...orders, id: 'd4e5f6', name: 'accounts-api'}],
})))
const result = await adapter.list({search: 'ORDERS'})
expect(result.map((resource) => resource.name)).toEqual(['orders-api'])
})
test('gets and maps one REST API', async () => {
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
expect(command).toBeInstanceOf(GetRestApiCommand)
return orders
}))
const result = await adapter.get('a1b2c3')
expect(result?.id).toBe('a1b2c3')
expect(result?.metadata.rootResourceId).toBe('root123')
})
test('returns null when API Gateway reports a missing REST API', async () => {
const adapter = new AwsApiGatewayAdapter(fakeClient(() => {
throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}})
}))
await expect(adapter.get('missing')).resolves.toBeNull()
})
test('creates a REST API with an optional description', async () => {
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
expect(command).toBeInstanceOf(CreateRestApiCommand)
expect((command as CreateRestApiCommand).input).toEqual({name: 'orders-api', description: 'Orders REST API'})
return orders
}))
const result = await adapter.create({values: {name: 'orders-api', description: 'Orders REST API'}})
expect(result.id).toBe('a1b2c3')
})
test('rejects a missing name before calling API Gateway', async () => {
let called = false
const adapter = new AwsApiGatewayAdapter(fakeClient(() => {
called = true
return {}
}))
await expect(adapter.create({values: {}})).rejects.toThrow('name is required')
expect(called).toBeFalse()
})
test('deletes the requested REST API', async () => {
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
expect(command).toBeInstanceOf(DeleteRestApiCommand)
expect((command as DeleteRestApiCommand).input.restApiId).toBe('a1b2c3')
return {}
}))
await adapter.delete('a1b2c3')
})
test('creates and deploys a Lambda proxy route', async () => {
const apiCommands: unknown[] = []
const lambdaCommands: unknown[] = []
const adapter = new AwsApiGatewayAdapter(
fakeClient((command) => {
apiCommands.push(command)
if (command instanceof GetResourcesCommand) return {items: [{id: 'root123', path: '/'}]}
if (command instanceof CreateResourceCommand) return {id: 'resource456'}
if (command instanceof CreateDeploymentCommand) return {id: 'deployment789'}
return {}
}),
fakeLambda((command) => {
lambdaCommands.push(command)
if (command instanceof GetFunctionCommand) {
return {Configuration: {FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:hello'}}
}
return {}
}),
'000000000000',
'us-east-1',
)
const result = await adapter.createApiGatewayLambdaRoute('a1b2c3', {
path: '/hello', method: 'GET', stage: 'dev', functionName: 'hello',
})
expect(apiCommands.map((command) => (command as {constructor: {name: string}}).constructor.name)).toEqual([
'GetResourcesCommand', 'CreateResourceCommand', 'PutMethodCommand',
'PutIntegrationCommand', 'CreateDeploymentCommand',
])
expect((apiCommands[1] as CreateResourceCommand).input).toMatchObject({
restApiId: 'a1b2c3', parentId: 'root123', pathPart: 'hello',
})
expect((apiCommands[2] as PutMethodCommand).input).toMatchObject({
httpMethod: 'GET', authorizationType: 'NONE',
})
expect((apiCommands[3] as PutIntegrationCommand).input).toMatchObject({
type: 'AWS_PROXY', integrationHttpMethod: 'POST',
uri: 'arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:000000000000:function:hello/invocations',
})
expect((lambdaCommands[1] as AddPermissionCommand).input).toMatchObject({
FunctionName: 'hello', Principal: 'apigateway.amazonaws.com',
SourceArn: 'arn:aws:execute-api:us-east-1:000000000000:a1b2c3/dev/GET/hello',
})
expect(result).toEqual({
resourceId: 'resource456', deploymentId: 'deployment789',
invokeUrl: 'http://localhost:4566/execute-api/a1b2c3/dev/hello',
})
})
test('returns the AWS API Gateway schema', () => {
const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({})))
expect(adapter.schema()).toMatchObject({
cloud: 'aws',
service: 'apigateway',
displayName: 'AWS API Gateway',
actions: ['list', 'create', 'delete', 'inspect'],
})
})
})
@@ -0,0 +1,198 @@
import {
CreateDeploymentCommand,
CreateResourceCommand,
CreateRestApiCommand,
DeleteRestApiCommand,
GetRestApiCommand,
GetRestApisCommand,
GetResourcesCommand,
PutIntegrationCommand,
PutMethodCommand,
type APIGatewayClient,
type RestApi,
} from '@aws-sdk/client-api-gateway'
import {AddPermissionCommand, GetFunctionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
import {apiGateway as defaultApiGateway, defaultAccountId, lambda as defaultLambda} from '../aws'
import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema'
import {ValidationError} from '../cloud-spi/errors'
import {awsEndpoint} from '../service/runtimeProbe'
import type {ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
export class AwsApiGatewayAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'apigateway' as const
constructor(
private readonly apiGateway: APIGatewayClient = defaultApiGateway,
private readonly lambda: LambdaClient = defaultLambda,
private readonly accountId = defaultAccountId(),
private readonly region = process.env.AWS_REGION ?? 'us-east-1',
) {}
schema(): ServiceSchema {
return awsApiGatewaySchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const apis: RestApi[] = []
let position: string | undefined
do {
const res = await this.apiGateway.send(new GetRestApisCommand({position}))
apis.push(...(res.items ?? []))
position = res.position
} while (position)
return filterBySearch(apis.map(toResource), query.search)
}
async get(id: string): Promise<CloudResource | null> {
try {
const res = await this.apiGateway.send(new GetRestApiCommand({restApiId: id}))
return toResource(res)
} catch (error) {
if (hasHttpStatus(error, 404)) return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const name = stringValue(input.values.name)
const description = stringValue(input.values.description)
if (!name) throw new ValidationError('name is required')
const res = await this.apiGateway.send(new CreateRestApiCommand({
name,
description: description || undefined,
}))
return toResource(res)
}
async delete(id: string): Promise<void> {
await this.apiGateway.send(new DeleteRestApiCommand({restApiId: id}))
}
async createApiGatewayLambdaRoute(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult> {
const pathPart = routePathPart(input.path)
const method = input.method.toUpperCase()
const stage = stageName(input.stage)
const functionName = stringValue(input.functionName)
if (!functionName) throw new ValidationError('functionName is required')
if (!['GET', 'POST', 'PUT', 'DELETE'].includes(method)) {
throw new ValidationError('method must be GET, POST, PUT, or DELETE')
}
const root = await this.rootResource(apiId)
const resource = await this.apiGateway.send(new CreateResourceCommand({
restApiId: apiId,
parentId: root.id,
pathPart,
}))
const resourceId = resource.id
if (!resourceId) throw new ValidationError('API Gateway did not return a resource id')
await this.apiGateway.send(new PutMethodCommand({
restApiId: apiId,
resourceId,
httpMethod: method,
authorizationType: 'NONE',
}))
const functionResult = await this.lambda.send(new GetFunctionCommand({FunctionName: functionName}))
const functionArn = functionResult.Configuration?.FunctionArn
if (!functionArn) throw new ValidationError(`Lambda function ${functionName} did not return an ARN`)
await this.apiGateway.send(new PutIntegrationCommand({
restApiId: apiId,
resourceId,
httpMethod: method,
type: 'AWS_PROXY',
integrationHttpMethod: 'POST',
uri: `arn:aws:apigateway:${this.region}:lambda:path/2015-03-31/functions/${functionArn}/invocations`,
}))
await this.lambda.send(new AddPermissionCommand({
FunctionName: functionName,
StatementId: `apigateway-${apiId}-${stage}-${method}-${pathPart}`,
Action: 'lambda:InvokeFunction',
Principal: 'apigateway.amazonaws.com',
SourceArn: `arn:aws:execute-api:${this.region}:${this.accountId}:${apiId}/${stage}/${method}/${pathPart}`,
}))
const deployment = await this.apiGateway.send(new CreateDeploymentCommand({
restApiId: apiId,
stageName: stage,
description: `Lambda proxy route ${method} /${pathPart}`,
}))
if (!deployment.id) throw new ValidationError('API Gateway did not return a deployment id')
return {
resourceId,
deploymentId: deployment.id,
invokeUrl: `${awsEndpoint().replace(/\/$/, '')}/execute-api/${apiId}/${stage}/${pathPart}`,
}
}
private async rootResource(apiId: string) {
let position: string | undefined
do {
const result = await this.apiGateway.send(new GetResourcesCommand({restApiId: apiId, position}))
const root = result.items?.find((resource) => resource.path === '/')
if (root?.id) return root
position = result.position
} while (position)
throw new ValidationError('API Gateway root resource was not found')
}
}
function routePathPart(path: string): string {
const value = stringValue(path).replace(/^\//, '')
if (!/^[A-Za-z0-9._~-]+$/.test(value)) {
throw new ValidationError('path must be one URL segment, for example /orders')
}
return value
}
function stageName(stage: string): string {
const value = stringValue(stage)
if (!/^[A-Za-z0-9_-]+$/.test(value)) {
throw new ValidationError('stage must contain only letters, numbers, underscores, or hyphens')
}
return value
}
function toResource(api: RestApi): CloudResource {
const id = api.id ?? ''
return {
id,
name: api.name ?? '',
cloud: 'aws',
service: 'apigateway',
type: 'rest-api',
region: null,
createdAt: api.createdDate?.toISOString() ?? null,
metadata: {
provider: 'aws',
apigatewayService: 'rest',
description: api.description,
version: api.version,
endpointTypes: api.endpointConfiguration?.types,
rootResourceId: api.rootResourceId,
},
}
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
}
function hasHttpStatus(error: unknown, status: number): boolean {
if (typeof error !== 'object' || error === null) return false
const metadata = (error as {$metadata?: {httpStatusCode?: number}}).$metadata
return metadata?.httpStatusCode === status
}
@@ -0,0 +1,206 @@
import {describe, expect, test} from 'bun:test'
import {
CreateApplicationCommand,
CreateEnvironmentCommand,
CreateHostedConfigurationVersionCommand,
DeleteEnvironmentCommand,
GetApplicationCommand,
GetHostedConfigurationVersionCommand,
ListApplicationsCommand,
ListConfigurationProfilesCommand,
ListDeploymentStrategiesCommand,
ListEnvironmentsCommand,
StartDeploymentCommand,
type AppConfigClient,
} from '@aws-sdk/client-appconfig'
import {AwsAppConfigAdapter} from './AwsAppConfigAdapter'
import {ValidationError} from '../cloud-spi/errors'
const APPLICATION = {Id: 'abc123', Name: 'orders', Description: 'Order service'}
const ENVIRONMENT = {ApplicationId: 'abc123', Id: 'env777', Name: 'dev', State: 'READY_FOR_DEPLOYMENT'}
const PROFILE = {ApplicationId: 'abc123', Id: 'prof999', Name: 'settings', LocationUri: 'hosted', Type: 'AWS.Freeform'}
const STRATEGY = {Id: 'strat555', Name: 'immediate', DeploymentDurationInMinutes: 0, GrowthFactor: 100}
const DEPLOYMENT = {
ApplicationId: 'abc123',
EnvironmentId: 'env777',
DeploymentStrategyId: 'strat555',
ConfigurationProfileId: 'prof999',
ConfigurationVersion: '1',
DeploymentNumber: 3,
State: 'COMPLETE',
}
function stubAppConfig(handlers: Record<string, () => object> = {}) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
for (const [name, handler] of Object.entries(handlers)) {
if (command.constructor.name === name) return handler()
}
return {}
},
} as unknown as AppConfigClient
return {client, sent}
}
describe('AwsAppConfigAdapter', () => {
test('identifies itself as the AWS configuration adapter', () => {
const adapter = new AwsAppConfigAdapter(stubAppConfig().client)
expect(adapter.cloud).toBe('aws')
expect(adapter.service).toBe('configuration')
expect(adapter.schema().displayName).toBe('AppConfig')
expect(adapter.schema().actions).toEqual(['list', 'create', 'delete', 'inspect'])
})
test('allows AWS AppConfig application names up to 255 characters', () => {
const field = new AwsAppConfigAdapter(stubAppConfig().client).schema().fields[0]
expect(field.validation?.maxLength).toBe(255)
})
test('lists applications as top-level resources', async () => {
const {client} = stubAppConfig({ListApplicationsCommand: () => ({Items: [APPLICATION]})})
const [resource] = await new AwsAppConfigAdapter(client).list()
expect(resource).toMatchObject({
id: 'abc123',
name: 'orders',
cloud: 'aws',
service: 'configuration',
type: 'appconfig-application',
})
expect(resource.metadata.description).toBe('Order service')
})
test('returns null when an application is missing', async () => {
const missing = () => {
const error = new Error('not found')
error.name = 'ResourceNotFoundException'
throw error
}
const {client} = stubAppConfig({GetApplicationCommand: missing})
await expect(new AwsAppConfigAdapter(client).get('nope')).resolves.toBeNull()
})
test('creates an application from name and description', async () => {
const {client, sent} = stubAppConfig({CreateApplicationCommand: () => APPLICATION})
const resource = await new AwsAppConfigAdapter(client).create({values: {name: 'orders', description: 'Order service'}})
expect(resource.id).toBe('abc123')
expect((sent[0] as CreateApplicationCommand).input).toEqual({Name: 'orders', Description: 'Order service'})
})
test('rejects an application create without a name', async () => {
const adapter = new AwsAppConfigAdapter(stubAppConfig().client)
await expect(adapter.create({values: {}})).rejects.toBeInstanceOf(ValidationError)
})
test('lists environments scoped to an application', async () => {
const {client, sent} = stubAppConfig({ListEnvironmentsCommand: () => ({Items: [ENVIRONMENT]})})
const [environment] = await new AwsAppConfigAdapter(client).listAppConfigEnvironments('abc123')
expect(environment).toEqual({
id: 'env777',
applicationId: 'abc123',
name: 'dev',
description: null,
state: 'READY_FOR_DEPLOYMENT',
})
expect((sent[0] as ListEnvironmentsCommand).input.ApplicationId).toBe('abc123')
})
test('creates an environment under an application', async () => {
const {client, sent} = stubAppConfig({CreateEnvironmentCommand: () => ENVIRONMENT})
const environment = await new AwsAppConfigAdapter(client).createAppConfigEnvironment('abc123', {values: {name: 'dev'}})
expect(environment.id).toBe('env777')
expect((sent[0] as CreateEnvironmentCommand).input).toEqual({ApplicationId: 'abc123', Name: 'dev'})
})
test('deletes an environment by application and environment id', async () => {
const {client, sent} = stubAppConfig()
await new AwsAppConfigAdapter(client).deleteAppConfigEnvironment('abc123', 'env777')
expect((sent[0] as DeleteEnvironmentCommand).input).toEqual({ApplicationId: 'abc123', EnvironmentId: 'env777'})
})
test('lists configuration profiles with type and location', async () => {
const {client} = stubAppConfig({ListConfigurationProfilesCommand: () => ({Items: [PROFILE]})})
const [profile] = await new AwsAppConfigAdapter(client).listAppConfigConfigurationProfiles('abc123')
expect(profile).toEqual({
id: 'prof999',
applicationId: 'abc123',
name: 'settings',
description: null,
locationUri: 'hosted',
type: 'AWS.Freeform',
})
})
test('decodes hosted configuration version content to text', async () => {
const {client} = stubAppConfig({
GetHostedConfigurationVersionCommand: () => ({
...PROFILE,
ConfigurationProfileId: 'prof999',
VersionNumber: 1,
ContentType: 'application/json',
Content: new TextEncoder().encode('{"theme":"dark"}'),
}),
})
const version = await new AwsAppConfigAdapter(client).getAppConfigHostedConfigurationVersion('abc123', 'prof999', 1)
expect(version?.content).toBe('{"theme":"dark"}')
expect(version?.contentType).toBe('application/json')
})
test('encodes hosted configuration content as bytes', async () => {
const {client, sent} = stubAppConfig({CreateHostedConfigurationVersionCommand: () => ({VersionNumber: 2})})
await new AwsAppConfigAdapter(client).createAppConfigHostedConfigurationVersion('abc123', 'prof999', {
values: {content: '\n{"theme":"dark"}\n', contentType: 'application/json'},
})
const input = (sent[0] as CreateHostedConfigurationVersionCommand).input
expect(input.ApplicationId).toBe('abc123')
expect(input.ConfigurationProfileId).toBe('prof999')
expect(input.ContentType).toBe('application/json')
expect(input.Content).toEqual(new TextEncoder().encode('\n{"theme":"dark"}\n'))
})
test('lists deployment strategies without an application scope', async () => {
const {client} = stubAppConfig({ListDeploymentStrategiesCommand: () => ({Items: [STRATEGY]})})
const [strategy] = await new AwsAppConfigAdapter(client).listAppConfigDeploymentStrategies()
expect(strategy).toEqual({
id: 'strat555',
name: 'immediate',
description: null,
deploymentDurationInMinutes: 0,
growthType: null,
growthFactor: 100,
finalBakeTimeInMinutes: null,
replicateTo: null,
})
})
test('starts a deployment with the selected ids', async () => {
const {client, sent} = stubAppConfig({StartDeploymentCommand: () => DEPLOYMENT})
const deployment = await new AwsAppConfigAdapter(client).startAppConfigDeployment('abc123', 'env777', {
values: {
configurationProfileId: 'prof999',
configurationVersion: '1',
deploymentStrategyId: 'strat555',
},
})
expect(deployment.deploymentNumber).toBe(3)
expect(deployment.state).toBe('COMPLETE')
expect((sent[0] as StartDeploymentCommand).input).toEqual({
ApplicationId: 'abc123',
EnvironmentId: 'env777',
DeploymentStrategyId: 'strat555',
ConfigurationProfileId: 'prof999',
ConfigurationVersion: '1',
})
})
})
@@ -0,0 +1,388 @@
import {
CreateApplicationCommand,
CreateConfigurationProfileCommand,
CreateDeploymentStrategyCommand,
CreateEnvironmentCommand,
CreateHostedConfigurationVersionCommand,
DeleteApplicationCommand,
DeleteConfigurationProfileCommand,
DeleteDeploymentStrategyCommand,
DeleteEnvironmentCommand,
DeleteHostedConfigurationVersionCommand,
GetApplicationCommand,
GetDeploymentCommand,
GetHostedConfigurationVersionCommand,
ListApplicationsCommand,
ListConfigurationProfilesCommand,
ListDeploymentStrategiesCommand,
ListEnvironmentsCommand,
ListHostedConfigurationVersionsCommand,
StartDeploymentCommand,
type AppConfigClient,
type Application,
type ConfigurationProfile,
type ConfigurationProfileSummary,
type Deployment,
type DeploymentStrategy,
type Environment,
type HostedConfigurationVersion,
} from '@aws-sdk/client-appconfig'
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
import {ValidationError} from '../cloud-spi/errors'
import type {
AppConfigConfigurationProfile,
AppConfigDeployment,
AppConfigDeploymentStrategy,
AppConfigEnvironment,
AppConfigHostedConfigurationVersion,
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
/** AWS AppConfig applications and their nested configuration resources. */
export class AwsAppConfigAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'configuration' as const
constructor(private readonly appConfig: AppConfigClient) {}
schema(): ServiceSchema {
return awsAppConfigSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const applications: Application[] = []
let nextToken: string | undefined
do {
const response = await this.appConfig.send(new ListApplicationsCommand({NextToken: nextToken}))
applications.push(...(response.Items ?? []))
nextToken = response.NextToken
} while (nextToken)
const resources = applications.map(applicationResource)
return filterBySearch(resources, query.search)
}
async get(id: string): Promise<CloudResource | null> {
try {
return applicationResource(await this.appConfig.send(new GetApplicationCommand({ApplicationId: id})))
} catch (error) {
if (isNotFound(error)) return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const name = requiredValue(input.values.name, 'name')
const description = stringValue(input.values.description) || undefined
return applicationResource(
await this.appConfig.send(new CreateApplicationCommand({Name: name, Description: description})),
)
}
async delete(id: string): Promise<void> {
await this.appConfig.send(new DeleteApplicationCommand({ApplicationId: id}))
}
async listAppConfigEnvironments(applicationId: string): Promise<AppConfigEnvironment[]> {
const environments: Environment[] = []
let nextToken: string | undefined
do {
const response = await this.appConfig.send(
new ListEnvironmentsCommand({ApplicationId: applicationId, NextToken: nextToken}),
)
environments.push(...(response.Items ?? []))
nextToken = response.NextToken
} while (nextToken)
return environments.map((environment) => environmentResource(environment, applicationId))
}
async createAppConfigEnvironment(applicationId: string, input: CreateResourceInput): Promise<AppConfigEnvironment> {
const name = requiredValue(input.values.name, 'name')
const description = stringValue(input.values.description) || undefined
return environmentResource(
await this.appConfig.send(new CreateEnvironmentCommand({ApplicationId: applicationId, Name: name, Description: description})),
applicationId,
)
}
async deleteAppConfigEnvironment(applicationId: string, environmentId: string): Promise<void> {
await this.appConfig.send(new DeleteEnvironmentCommand({ApplicationId: applicationId, EnvironmentId: environmentId}))
}
async listAppConfigConfigurationProfiles(applicationId: string): Promise<AppConfigConfigurationProfile[]> {
const profiles: ConfigurationProfileSummary[] = []
let nextToken: string | undefined
do {
const response = await this.appConfig.send(
new ListConfigurationProfilesCommand({ApplicationId: applicationId, NextToken: nextToken}),
)
profiles.push(...(response.Items ?? []))
nextToken = response.NextToken
} while (nextToken)
return profiles.map((profile) => profileResource(profile, applicationId))
}
async createAppConfigConfigurationProfile(applicationId: string, input: CreateResourceInput): Promise<AppConfigConfigurationProfile> {
const name = requiredValue(input.values.name, 'name')
const locationUri = requiredValue(input.values.locationUri, 'locationUri')
const description = stringValue(input.values.description) || undefined
const type = stringValue(input.values.type) || 'AWS.Freeform'
return profileResource(
await this.appConfig.send(new CreateConfigurationProfileCommand({
ApplicationId: applicationId,
Name: name,
Description: description,
LocationUri: locationUri,
Type: type,
})),
applicationId,
)
}
async deleteAppConfigConfigurationProfile(applicationId: string, profileId: string): Promise<void> {
await this.appConfig.send(new DeleteConfigurationProfileCommand({ApplicationId: applicationId, ConfigurationProfileId: profileId}))
}
async listAppConfigHostedConfigurationVersions(applicationId: string, profileId: string): Promise<AppConfigHostedConfigurationVersion[]> {
const versions: HostedConfigurationVersion[] = []
let nextToken: string | undefined
do {
const response = await this.appConfig.send(
new ListHostedConfigurationVersionsCommand({
ApplicationId: applicationId,
ConfigurationProfileId: profileId,
NextToken: nextToken,
}),
)
versions.push(...(response.Items ?? []))
nextToken = response.NextToken
} while (nextToken)
return versions.map((version) => versionResource(version, applicationId, profileId))
}
async getAppConfigHostedConfigurationVersion(
applicationId: string,
profileId: string,
versionNumber: number,
): Promise<AppConfigHostedConfigurationVersion | null> {
try {
const response = await this.appConfig.send(new GetHostedConfigurationVersionCommand({
ApplicationId: applicationId,
ConfigurationProfileId: profileId,
VersionNumber: versionNumber,
}))
return versionResource(response, applicationId, profileId)
} catch (error) {
if (isNotFound(error)) return null
throw error
}
}
async createAppConfigHostedConfigurationVersion(
applicationId: string,
profileId: string,
input: CreateResourceInput,
): Promise<AppConfigHostedConfigurationVersion> {
const content = input.values.content
if (typeof content !== 'string' || !content.trim()) throw new ValidationError('content is required')
const contentType = requiredValue(input.values.contentType, 'contentType')
const description = stringValue(input.values.description) || undefined
const response = await this.appConfig.send(new CreateHostedConfigurationVersionCommand({
ApplicationId: applicationId,
ConfigurationProfileId: profileId,
Content: new TextEncoder().encode(content),
ContentType: contentType,
Description: description,
}))
return versionResource(response, applicationId, profileId)
}
async deleteAppConfigHostedConfigurationVersion(applicationId: string, profileId: string, versionNumber: number): Promise<void> {
await this.appConfig.send(new DeleteHostedConfigurationVersionCommand({
ApplicationId: applicationId,
ConfigurationProfileId: profileId,
VersionNumber: versionNumber,
}))
}
async listAppConfigDeploymentStrategies(): Promise<AppConfigDeploymentStrategy[]> {
const strategies: DeploymentStrategy[] = []
let nextToken: string | undefined
do {
const response = await this.appConfig.send(new ListDeploymentStrategiesCommand({NextToken: nextToken}))
strategies.push(...(response.Items ?? []))
nextToken = response.NextToken
} while (nextToken)
return strategies.map(strategyResource)
}
async createAppConfigDeploymentStrategy(input: CreateResourceInput): Promise<AppConfigDeploymentStrategy> {
const name = requiredValue(input.values.name, 'name')
const deploymentDurationInMinutes = requiredNumber(input.values.deploymentDurationInMinutes, 'deploymentDurationInMinutes')
const growthFactor = requiredNumber(input.values.growthFactor, 'growthFactor')
const description = stringValue(input.values.description) || undefined
const finalBakeTimeInMinutes = numberValue(input.values.finalBakeTimeInMinutes)
const response = await this.appConfig.send(new CreateDeploymentStrategyCommand({
Name: name,
Description: description,
DeploymentDurationInMinutes: deploymentDurationInMinutes,
FinalBakeTimeInMinutes: finalBakeTimeInMinutes ?? 0,
GrowthFactor: growthFactor,
ReplicateTo: 'NONE',
}))
return strategyResource(response)
}
async deleteAppConfigDeploymentStrategy(strategyId: string): Promise<void> {
await this.appConfig.send(new DeleteDeploymentStrategyCommand({DeploymentStrategyId: strategyId}))
}
async startAppConfigDeployment(applicationId: string, environmentId: string, input: CreateResourceInput): Promise<AppConfigDeployment> {
const configurationProfileId = requiredValue(input.values.configurationProfileId, 'configurationProfileId')
const configurationVersion = requiredValue(input.values.configurationVersion, 'configurationVersion')
const deploymentStrategyId = requiredValue(input.values.deploymentStrategyId, 'deploymentStrategyId')
const description = stringValue(input.values.description) || undefined
return deploymentResource(await this.appConfig.send(new StartDeploymentCommand({
ApplicationId: applicationId,
EnvironmentId: environmentId,
DeploymentStrategyId: deploymentStrategyId,
ConfigurationProfileId: configurationProfileId,
ConfigurationVersion: configurationVersion,
Description: description,
})))
}
async getAppConfigDeployment(applicationId: string, environmentId: string, deploymentNumber: number): Promise<AppConfigDeployment | null> {
try {
return deploymentResource(await this.appConfig.send(new GetDeploymentCommand({
ApplicationId: applicationId,
EnvironmentId: environmentId,
DeploymentNumber: deploymentNumber,
})))
} catch (error) {
if (isNotFound(error)) return null
throw error
}
}
async health(): Promise<void> {
await this.appConfig.send(new ListApplicationsCommand({MaxResults: 1}))
}
}
function applicationResource(application: Application): CloudResource {
return {
id: application.Id ?? '',
name: application.Name ?? '',
cloud: 'aws',
service: 'configuration',
type: 'appconfig-application',
region: null,
createdAt: null,
status: null,
metadata: {description: application.Description},
}
}
function environmentResource(environment: Environment, applicationId: string): AppConfigEnvironment {
return {
id: environment.Id ?? '',
applicationId: environment.ApplicationId ?? applicationId,
name: environment.Name ?? '',
description: environment.Description ?? null,
state: environment.State ?? null,
}
}
function profileResource(profile: ConfigurationProfile | ConfigurationProfileSummary, applicationId: string): AppConfigConfigurationProfile {
return {
id: profile.Id ?? '',
applicationId: profile.ApplicationId ?? applicationId,
name: profile.Name ?? '',
description: 'Description' in profile ? profile.Description ?? null : null,
locationUri: profile.LocationUri ?? null,
type: profile.Type ?? null,
}
}
function versionResource(
version: HostedConfigurationVersion,
applicationId: string,
profileId: string,
): AppConfigHostedConfigurationVersion {
return {
id: String(version.VersionNumber ?? ''),
applicationId: version.ApplicationId ?? applicationId,
configurationProfileId: version.ConfigurationProfileId ?? profileId,
versionNumber: version.VersionNumber ?? 0,
description: version.Description ?? null,
contentType: version.ContentType ?? null,
content: version.Content ? new TextDecoder().decode(version.Content) : null,
}
}
function strategyResource(strategy: DeploymentStrategy): AppConfigDeploymentStrategy {
return {
id: strategy.Id ?? '',
name: strategy.Name ?? '',
description: strategy.Description ?? null,
deploymentDurationInMinutes: strategy.DeploymentDurationInMinutes ?? null,
growthType: strategy.GrowthType ?? null,
growthFactor: strategy.GrowthFactor ?? null,
finalBakeTimeInMinutes: strategy.FinalBakeTimeInMinutes ?? null,
replicateTo: strategy.ReplicateTo ?? null,
}
}
function deploymentResource(deployment: Deployment): AppConfigDeployment {
return {
applicationId: deployment.ApplicationId ?? '',
environmentId: deployment.EnvironmentId ?? '',
deploymentNumber: deployment.DeploymentNumber ?? 0,
configurationProfileId: deployment.ConfigurationProfileId ?? null,
configurationVersion: deployment.ConfigurationVersion ?? null,
deploymentStrategyId: deployment.DeploymentStrategyId ?? null,
state: deployment.State ?? null,
percentageComplete: deployment.PercentageComplete ?? null,
startedAt: deployment.StartedAt?.toISOString() ?? null,
completedAt: deployment.CompletedAt?.toISOString() ?? null,
description: deployment.Description ?? null,
}
}
function requiredValue(value: unknown, field: string): string {
const result = stringValue(value)
if (!result) throw new ValidationError(`${field} is required`)
return result
}
function requiredNumber(value: unknown, field: string): number {
const result = Number(value)
if (!Number.isFinite(result)) throw new ValidationError(`${field} must be a number`)
return result
}
function numberValue(value: unknown): number | null {
const result = Number(value)
return Number.isFinite(result) ? result : null
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
}
function isNotFound(error: unknown): boolean {
if (!error || typeof error !== 'object') return false
const value = error as {name?: string; $metadata?: {httpStatusCode?: number}}
return value.name === 'ResourceNotFoundException' || value.$metadata?.httpStatusCode === 404
}
@@ -0,0 +1,198 @@
import {describe, expect, test} from 'bun:test'
import {
CreateStackCommand,
DeleteStackCommand,
DescribeStacksCommand,
ListStackResourcesCommand,
ListStacksCommand,
type CloudFormationClient,
} from '@aws-sdk/client-cloudformation'
import {AwsCloudFormationAdapter} from './AwsCloudFormationAdapter'
function fakeClient(handler: (command: unknown) => unknown): CloudFormationClient {
return {
send: async (command: unknown) => handler(command),
} as unknown as CloudFormationClient
}
const ordersStackSummary = {
StackId: 'arn:aws:cloudformation:us-east-1:000000000000:stack/orders-stack/abc123',
StackName: 'orders-stack',
CreationTime: new Date('2026-01-02T03:04:05.000Z'),
StackStatus: 'CREATE_COMPLETE',
}
const ordersStack = {
StackId: 'arn:aws:cloudformation:us-east-1:000000000000:stack/orders-stack/abc123',
StackName: 'orders-stack',
CreationTime: new Date('2026-01-02T03:04:05.000Z'),
LastUpdatedTime: new Date('2026-01-02T03:04:06.000Z'),
StackStatus: 'CREATE_COMPLETE',
Capabilities: [],
Outputs: [{OutputKey: 'BucketName', OutputValue: 'orders-bucket', Description: 'The bucket name'}],
Tags: [{Key: 'env', Value: 'dev'}],
EnableTerminationProtection: false,
}
const bucketResource = {
LogicalResourceId: 'OrdersBucket',
PhysicalResourceId: 'orders-bucket',
ResourceType: 'AWS::S3::Bucket',
ResourceStatus: 'CREATE_COMPLETE',
}
describe('AwsCloudFormationAdapter', () => {
test('lists every page and maps stacks to normalized resources', async () => {
const adapter = new AwsCloudFormationAdapter(fakeClient((command) => {
if (!(command instanceof ListStacksCommand)) throw new Error('Unexpected command')
if (!command.input.NextToken) return {StackSummaries: [ordersStackSummary], NextToken: 'next'}
return {StackSummaries: [{...ordersStackSummary, StackName: 'accounts-stack'}]}
}))
const result = await adapter.list()
expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({
id: 'orders-stack',
name: 'orders-stack',
cloud: 'aws',
service: 'iac',
type: 'stack',
region: null,
createdAt: '2026-01-02T03:04:05.000Z',
status: 'CREATE_COMPLETE',
})
expect(result[0].metadata).toMatchObject({
stackId: ordersStackSummary.StackId,
})
})
test('filters stacks by search term', async () => {
const adapter = new AwsCloudFormationAdapter(fakeClient(() => ({
StackSummaries: [ordersStackSummary, {...ordersStackSummary, StackName: 'accounts-stack'}],
})))
const result = await adapter.list({search: 'ORDERS'})
expect(result.map((resource) => resource.name)).toEqual(['orders-stack'])
})
test('gets and maps one stack, including its resources', async () => {
const adapter = new AwsCloudFormationAdapter(fakeClient((command) => {
if (command instanceof DescribeStacksCommand) return {Stacks: [ordersStack]}
if (command instanceof ListStackResourcesCommand) return {StackResourceSummaries: [bucketResource]}
throw new Error('Unexpected command')
}))
const result = await adapter.get('orders-stack')
expect(result?.id).toBe('orders-stack')
expect(result?.status).toBe('CREATE_COMPLETE')
expect(result?.metadata.outputs).toEqual([{key: 'BucketName', value: 'orders-bucket', description: 'The bucket name'}])
expect(result?.metadata.tags).toEqual([{key: 'env', value: 'dev'}])
expect(result?.metadata.resources).toEqual([{
logicalId: 'OrdersBucket',
physicalId: 'orders-bucket',
resourceType: 'AWS::S3::Bucket',
status: 'CREATE_COMPLETE',
}])
})
test('paginates ListStackResources and combines resources from every page', async () => {
const adapter = new AwsCloudFormationAdapter(fakeClient((command) => {
if (command instanceof DescribeStacksCommand) return {Stacks: [ordersStack]}
if (command instanceof ListStackResourcesCommand) {
if (!command.input.NextToken) {
return {StackResourceSummaries: [bucketResource], NextToken: 'next-page'}
}
return {StackResourceSummaries: [{...bucketResource, LogicalResourceId: 'OrdersQueue', PhysicalResourceId: 'orders-queue', ResourceType: 'AWS::SQS::Queue'}]}
}
throw new Error('Unexpected command')
}))
const result = await adapter.get('orders-stack')
expect(result?.metadata.resources).toEqual([
{
logicalId: 'OrdersBucket',
physicalId: 'orders-bucket',
resourceType: 'AWS::S3::Bucket',
status: 'CREATE_COMPLETE',
},
{
logicalId: 'OrdersQueue',
physicalId: 'orders-queue',
resourceType: 'AWS::SQS::Queue',
status: 'CREATE_COMPLETE',
},
])
})
test('returns null when CloudFormation reports a missing stack', async () => {
const adapter = new AwsCloudFormationAdapter(fakeClient(() => {
throw Object.assign(new Error('Stack with id missing-stack does not exist'), {name: 'ValidationError'})
}))
await expect(adapter.get('missing-stack')).resolves.toBeNull()
})
test('creates a stack from a stack name and template body', async () => {
const adapter = new AwsCloudFormationAdapter(fakeClient((command) => {
expect(command).toBeInstanceOf(CreateStackCommand)
expect((command as CreateStackCommand).input).toEqual({
StackName: 'orders-stack',
TemplateBody: '{"Resources":{}}',
})
return {StackId: ordersStack.StackId}
}))
const result = await adapter.create({values: {stackName: 'orders-stack', templateBody: '{"Resources":{}}'}})
expect(result.id).toBe('orders-stack')
expect(result.status).toBe('CREATE_IN_PROGRESS')
expect(result.metadata.stackId).toBe(ordersStack.StackId)
})
test('rejects a missing stack name before calling CloudFormation', async () => {
let called = false
const adapter = new AwsCloudFormationAdapter(fakeClient(() => {
called = true
return {}
}))
await expect(adapter.create({values: {templateBody: '{}'}})).rejects.toThrow('stackName is required')
expect(called).toBeFalse()
})
test('rejects a missing template body before calling CloudFormation', async () => {
let called = false
const adapter = new AwsCloudFormationAdapter(fakeClient(() => {
called = true
return {}
}))
await expect(adapter.create({values: {stackName: 'orders-stack'}})).rejects.toThrow('templateBody is required')
expect(called).toBeFalse()
})
test('deletes the requested stack', async () => {
const adapter = new AwsCloudFormationAdapter(fakeClient((command) => {
expect(command).toBeInstanceOf(DeleteStackCommand)
expect((command as DeleteStackCommand).input.StackName).toBe('orders-stack')
return {}
}))
await adapter.delete('orders-stack')
})
test('returns the AWS CloudFormation schema', () => {
const adapter = new AwsCloudFormationAdapter(fakeClient(() => ({})))
expect(adapter.schema()).toMatchObject({
cloud: 'aws',
service: 'iac',
displayName: 'CloudFormation',
actions: ['list', 'create', 'delete', 'inspect'],
})
})
})
@@ -0,0 +1,160 @@
import {
CloudFormationClient,
CreateStackCommand,
DeleteStackCommand,
DescribeStacksCommand,
ListStackResourcesCommand,
ListStacksCommand,
type Stack,
type StackResourceSummary,
type StackSummary,
} from '@aws-sdk/client-cloudformation'
import {cloudformation as defaultCloudFormation} from '../aws'
import {awsCloudformationSchema} from '../cloud-spi/cloudformationSchema'
import {ValidationError} from '../cloud-spi/errors'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
export class AwsCloudFormationAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'iac' as const
constructor(private readonly cloudformation: CloudFormationClient = defaultCloudFormation) {}
schema(): ServiceSchema {
return awsCloudformationSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const summaries: StackSummary[] = []
let nextToken: string | undefined
do {
const res = await this.cloudformation.send(new ListStacksCommand({NextToken: nextToken}))
summaries.push(...(res.StackSummaries ?? []))
nextToken = res.NextToken
} while (nextToken)
return filterBySearch(summaries.map(summaryToResource), query.search)
}
async get(id: string): Promise<CloudResource | null> {
try {
const describe = await this.cloudformation.send(new DescribeStacksCommand({StackName: id}))
const stack = describe.Stacks?.[0]
if (!stack) return null
const resourceSummaries: StackResourceSummary[] = []
let nextToken: string | undefined
do {
const resources = await this.cloudformation.send(new ListStackResourcesCommand({StackName: id, NextToken: nextToken}))
resourceSummaries.push(...(resources.StackResourceSummaries ?? []))
nextToken = resources.NextToken
} while (nextToken)
return stackToResource(stack, resourceSummaries)
} catch (error) {
if (isStackNotFound(error)) return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const stackName = stringValue(input.values.stackName)
const templateBody = stringValue(input.values.templateBody)
if (!stackName) throw new ValidationError('stackName is required')
if (!templateBody) throw new ValidationError('templateBody is required')
const res = await this.cloudformation.send(new CreateStackCommand({StackName: stackName, TemplateBody: templateBody}))
return {
id: stackName,
name: stackName,
cloud: 'aws',
service: 'iac',
type: 'stack',
region: null,
createdAt: null,
status: 'CREATE_IN_PROGRESS',
metadata: {
provider: 'aws',
stackId: res.StackId,
},
}
}
async delete(id: string): Promise<void> {
await this.cloudformation.send(new DeleteStackCommand({StackName: id}))
}
}
function summaryToResource(summary: StackSummary): CloudResource {
const id = summary.StackName ?? ''
return {
id,
name: id,
cloud: 'aws',
service: 'iac',
type: 'stack',
region: null,
createdAt: summary.CreationTime?.toISOString() ?? null,
status: summary.StackStatus ?? null,
metadata: {
provider: 'aws',
stackId: summary.StackId,
lastUpdatedTime: summary.LastUpdatedTime?.toISOString(),
},
}
}
function stackToResource(stack: Stack, resources: StackResourceSummary[]): CloudResource {
const id = stack.StackName ?? ''
return {
id,
name: id,
cloud: 'aws',
service: 'iac',
type: 'stack',
region: null,
createdAt: stack.CreationTime?.toISOString() ?? null,
status: stack.StackStatus ?? null,
metadata: {
provider: 'aws',
stackId: stack.StackId,
description: stack.Description,
lastUpdatedTime: stack.LastUpdatedTime?.toISOString(),
capabilities: stack.Capabilities,
enableTerminationProtection: stack.EnableTerminationProtection,
outputs: (stack.Outputs ?? []).map((output) => ({
key: output.OutputKey,
value: output.OutputValue,
description: output.Description,
})),
tags: (stack.Tags ?? []).map((tag) => ({key: tag.Key, value: tag.Value})),
resources: resources.map((resource) => ({
logicalId: resource.LogicalResourceId,
physicalId: resource.PhysicalResourceId,
resourceType: resource.ResourceType,
status: resource.ResourceStatus,
})),
},
}
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
}
/**
* DescribeStacks on a missing stack answers a 400 ValidationError with a
* "does not exist" message rather than a 404 — verified against the local
* Floci CloudFormation emulator, which matches real AWS behaviour.
*/
function isStackNotFound(error: unknown): boolean {
if (!(error instanceof Error)) return false
return error.name === 'ValidationError' && /does not exist/i.test(error.message)
}
@@ -0,0 +1,144 @@
import {describe, expect, test} from 'bun:test'
import {AwsComputeAdapter} from './AwsComputeAdapter'
import type {Ec2Instance, RunInstanceInput} from '../services/ec2'
const baseInstance: Ec2Instance = {
instanceId: 'i-0abc123def456',
name: 'web-server-1',
state: 'running',
instanceType: 't3.micro',
availabilityZone: 'us-east-1a',
publicIpAddress: '54.1.2.3',
privateIpAddress: '10.0.1.5',
vpcId: 'vpc-abc',
subnetId: 'subnet-abc',
imageId: 'ami-0abcdef',
keyName: 'my-key',
launchTime: '2025-01-01T00:00:00.000Z',
architecture: 'x86_64',
platform: undefined,
securityGroups: [{id: 'sg-abc', name: 'default'}],
tags: [{key: 'Name', value: 'web-server-1'}],
}
function fakeService(overrides: Partial<{
listInstances: () => Promise<Ec2Instance[]>
describeInstance: (id: string) => Promise<Ec2Instance>
terminateInstance: (id: string) => Promise<void>
listAmis: () => Promise<[]>
runInstance: (input: RunInstanceInput) => Promise<Ec2Instance>
}> = {}) {
return {
listInstances: async () => [baseInstance],
describeInstance: async (_id: string) => baseInstance,
terminateInstance: async (_id: string) => {},
listAmis: async () => [] as [],
runInstance: async (_input: RunInstanceInput) => baseInstance,
...overrides,
}
}
describe('AwsComputeAdapter', () => {
test('list returns mapped CloudResource array', async () => {
const adapter = new AwsComputeAdapter(fakeService())
const result = await adapter.list()
expect(result).toHaveLength(1)
expect(result[0].id).toBe('i-0abc123def456')
expect(result[0].name).toBe('web-server-1')
expect(result[0].cloud).toBe('aws')
expect(result[0].service).toBe('compute')
expect(result[0].type).toBe('instance')
expect(result[0].status).toBe('running')
expect(result[0].region).toBe('us-east-1a')
})
test('list filters results by search term', async () => {
const adapter = new AwsComputeAdapter(fakeService({
listInstances: async () => [
baseInstance,
{...baseInstance, instanceId: 'i-other', name: 'db-server-1'},
],
}))
const result = await adapter.list({search: 'web'})
expect(result).toHaveLength(1)
expect(result[0].name).toBe('web-server-1')
})
test('list returns all instances when search is empty', async () => {
const adapter = new AwsComputeAdapter(fakeService({
listInstances: async () => [
baseInstance,
{...baseInstance, instanceId: 'i-other', name: 'db-server-1'},
],
}))
const result = await adapter.list({})
expect(result).toHaveLength(2)
})
test('get returns null when instance has 404 http status', async () => {
const err = Object.assign(new Error('Not found'), {$metadata: {httpStatusCode: 404}})
const adapter = new AwsComputeAdapter(fakeService({
describeInstance: async () => { throw err },
}))
const result = await adapter.get('i-missing')
expect(result).toBeNull()
})
test('get rethrows non-404 errors', async () => {
const err = Object.assign(new Error('InternalError'), {$metadata: {httpStatusCode: 500}})
const adapter = new AwsComputeAdapter(fakeService({
describeInstance: async () => { throw err },
}))
await expect(adapter.get('i-bad')).rejects.toThrow('InternalError')
})
test('create calls runInstance and returns mapped CloudResource', async () => {
const launched: RunInstanceInput[] = []
const adapter = new AwsComputeAdapter(fakeService({
runInstance: async (input) => { launched.push(input); return baseInstance },
}))
const result = await adapter.create({values: {
name: 'test-box',
imageId: 'ami-0abcdef123',
instanceType: 't3.micro',
keyName: 'my-key',
subnetId: 'subnet-abc',
securityGroupIds: 'sg-111, sg-222',
}})
expect(launched).toHaveLength(1)
expect(launched[0].name).toBe('test-box')
expect(launched[0].imageId).toBe('ami-0abcdef123')
expect(launched[0].instanceType).toBe('t3.micro')
expect(launched[0].securityGroupIds).toEqual(['sg-111', 'sg-222'])
expect(result.type).toBe('instance')
expect(result.cloud).toBe('aws')
})
test('delete calls terminateInstance with the id', async () => {
const terminated: string[] = []
const adapter = new AwsComputeAdapter(fakeService({
terminateInstance: async (id) => { terminated.push(id) },
}))
await adapter.delete('i-0abc123def456')
expect(terminated).toEqual(['i-0abc123def456'])
})
test('schema returns aws compute schema', () => {
const adapter = new AwsComputeAdapter(fakeService())
const schema = adapter.schema()
expect(schema.cloud).toBe('aws')
expect(schema.service).toBe('compute')
expect(schema.displayName).toBe('Compute')
expect(schema.actions).toContain('list')
expect(schema.actions).toContain('inspect')
})
})
@@ -0,0 +1,128 @@
import {awsComputeSchema} from '../cloud-spi/computeSchema'
import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
import {ec2Service, type Ec2Image, type Ec2Instance, type RunInstanceInput} from '../services/ec2'
type Ec2ServiceShape = {
listInstances(): Promise<Ec2Instance[]>
describeInstance(instanceId: string): Promise<Ec2Instance>
terminateInstance(instanceId: string): Promise<void>
listAmis(): Promise<Ec2Image[]>
runInstance(input: RunInstanceInput): Promise<Ec2Instance>
}
export class AwsComputeAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'compute' as const
constructor(private readonly service_: Ec2ServiceShape = ec2Service) {}
schema(): ServiceSchema {
return awsComputeSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const [instances, images] = await Promise.all([
this.service_.listInstances(),
this.service_.listAmis(),
])
const resources = [
...instances.map(instanceToResource),
...images.map(imageToResource),
]
return filterBySearch(resources, query.search)
}
async get(id: string): Promise<CloudResource | null> {
try {
return instanceToResource(await this.service_.describeInstance(id))
} catch (error) {
if (hasHttpStatus(error, 404)) return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const v = input.values
const sgRaw = typeof v.securityGroupIds === 'string' ? v.securityGroupIds : ''
const securityGroupIds = sgRaw ? sgRaw.split(',').map((s: string) => s.trim()).filter(Boolean) : undefined
const runInput: RunInstanceInput = {
name: String(v.name ?? ''),
imageId: String(v.imageId ?? ''),
instanceType: String(v.instanceType ?? ''),
keyName: v.keyName ? String(v.keyName) : undefined,
subnetId: v.subnetId ? String(v.subnetId) : undefined,
securityGroupIds,
}
const instance = await this.service_.runInstance(runInput)
return instanceToResource(instance)
}
async delete(id: string): Promise<void> {
await this.service_.terminateInstance(id)
}
}
function instanceToResource(instance: Ec2Instance): CloudResource {
return {
id: instance.instanceId,
name: instance.name,
cloud: 'aws',
service: 'compute',
type: 'instance',
region: instance.availabilityZone ?? null,
createdAt: instance.launchTime ?? null,
status: instance.state ?? null,
metadata: {
instanceType: instance.instanceType,
publicIpAddress: instance.publicIpAddress,
privateIpAddress: instance.privateIpAddress,
vpcId: instance.vpcId,
subnetId: instance.subnetId,
imageId: instance.imageId,
keyName: instance.keyName,
architecture: instance.architecture,
platform: instance.platform,
securityGroups: instance.securityGroups,
tags: instance.tags,
},
}
}
function imageToResource(image: Ec2Image): CloudResource {
return {
id: image.imageId,
name: image.name,
cloud: 'aws',
service: 'compute',
type: 'image',
region: null,
createdAt: image.createdAt ?? null,
status: image.state ?? null,
metadata: {
architecture: image.architecture,
rootDeviceType: image.rootDeviceType,
virtualizationType: image.virtualizationType,
ownerId: image.ownerId,
public: image.public,
tags: image.tags,
},
}
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter((r) => r.name.toLowerCase().includes(normalized))
}
function hasHttpStatus(error: unknown, status: number): boolean {
if (typeof error !== 'object' || error === null) return false
const metadata = (error as {$metadata?: {httpStatusCode?: number}}).$metadata
return metadata?.httpStatusCode === status
}
@@ -0,0 +1,544 @@
import {describe, expect, test} from 'bun:test'
import {
CreateDBInstanceCommand,
CreateDBSnapshotCommand,
DeleteDBInstanceCommand,
DescribeDBInstancesCommand,
DescribeDBSnapshotsCommand,
DescribeOrderableDBInstanceOptionsCommand,
ListTagsForResourceCommand,
ModifyDBInstanceCommand,
type RDSClient,
} from '@aws-sdk/client-rds'
import {ConflictError, ValidationError} from '../cloud-spi/errors'
import {createRdsService} from '../services/rds'
import {mapAwsSdkError} from './awsErrors'
import {AwsDatabaseAdapter} from './AwsDatabaseAdapter'
type RdsCommand =
| CreateDBInstanceCommand
| CreateDBSnapshotCommand
| DeleteDBInstanceCommand
| DescribeDBInstancesCommand
| DescribeDBSnapshotsCommand
| DescribeOrderableDBInstanceOptionsCommand
| ListTagsForResourceCommand
| ModifyDBInstanceCommand
type Responder = (command: RdsCommand) => unknown | Promise<unknown>
function recordingRds(responder: Responder) {
const commands: RdsCommand[] = []
const client = {
async send(command: RdsCommand) {
commands.push(command)
return responder(command)
},
} as unknown as RDSClient
return {client, commands}
}
function adapterWith(responder: Responder) {
const recording = recordingRds(responder)
return {
...recording,
adapter: new AwsDatabaseAdapter(createRdsService(recording.client), recording.client),
}
}
function instanceResponse(command: CreateDBInstanceCommand) {
return {
DBInstance: {
DBInstanceIdentifier: command.input.DBInstanceIdentifier,
Engine: command.input.Engine,
EngineVersion: command.input.EngineVersion,
DBInstanceClass: command.input.DBInstanceClass,
AllocatedStorage: command.input.AllocatedStorage,
MasterUsername: command.input.MasterUsername,
DBName: command.input.DBName,
DBInstanceStatus: 'creating',
},
}
}
const validCreateValues = {
dbInstanceIdentifier: 'orders-db',
engine: 'postgres',
masterUserPassword: 'secret-pass',
}
describe('AwsDatabaseAdapter', () => {
test('keeps list search behavior intact', async () => {
const {adapter} = adapterWith(async (command) => {
expect(command).toBeInstanceOf(DescribeDBInstancesCommand)
return {
DBInstances: [
{DBInstanceIdentifier: 'orders-db', Engine: 'postgres', VpcSecurityGroups: []},
{DBInstanceIdentifier: 'users-db', Engine: 'mysql', VpcSecurityGroups: []},
],
}
})
const resources = await adapter.list({search: ' ORDERS '})
expect(resources.map(({id}) => id)).toEqual(['orders-db'])
})
test('keeps get 404 behavior intact', async () => {
const {adapter} = adapterWith(async () => {
throw Object.assign(new Error('not found'), {$metadata: {httpStatusCode: 404}})
})
await expect(adapter.get('missing-db')).resolves.toBeNull()
})
test('creates an instance with required values and provider defaults', async () => {
const {adapter, commands} = adapterWith((command) => {
expect(command).toBeInstanceOf(CreateDBInstanceCommand)
return instanceResponse(command as CreateDBInstanceCommand)
})
const resource = await adapter.create({values: {
dbInstanceIdentifier: ' orders-db ',
engine: 'postgres',
masterUserPassword: 'secret-pass',
}})
const createCommand = commands[0] as CreateDBInstanceCommand
const {MasterUserPassword, ...nonSensitiveInput} = createCommand.input
expect(nonSensitiveInput).toEqual({
DBInstanceIdentifier: 'orders-db',
Engine: 'postgres',
DBInstanceClass: 'db.t3.micro',
AllocatedStorage: 20,
MasterUsername: 'root',
})
expect(MasterUserPassword === validCreateValues.masterUserPassword).toBe(true)
expect(JSON.stringify(resource).includes(validCreateValues.masterUserPassword)).toBe(false)
expect(resource).toMatchObject({
id: 'orders-db',
name: 'orders-db',
type: 'db-instance',
status: 'creating',
engine: 'postgres',
instanceClass: 'db.t3.micro',
})
})
test('maps all nine create fields including security groups', async () => {
const {adapter, commands} = adapterWith((command) => instanceResponse(command as CreateDBInstanceCommand))
await adapter.create({values: {
dbInstanceIdentifier: 'orders-db',
engine: 'mariadb',
masterUserPassword: 'secret-pass',
dbInstanceClass: ' db.m6g.large ',
allocatedStorage: '100',
masterUsername: ' app_user ',
dbName: ' orders ',
engineVersion: ' 10.11 ',
securityGroupIds: 'sg-12345678, sg-87654321',
}})
const createCommand = commands[0] as CreateDBInstanceCommand
const {MasterUserPassword, ...nonSensitiveInput} = createCommand.input
expect(nonSensitiveInput).toEqual({
DBInstanceIdentifier: 'orders-db',
Engine: 'mariadb',
DBInstanceClass: 'db.m6g.large',
AllocatedStorage: 100,
MasterUsername: 'app_user',
DBName: 'orders',
EngineVersion: '10.11',
VpcSecurityGroupIds: ['sg-12345678', 'sg-87654321'],
})
expect(MasterUserPassword === validCreateValues.masterUserPassword).toBe(true)
})
test('accepts array of security group ids', async () => {
const {adapter, commands} = adapterWith((command) => instanceResponse(command as CreateDBInstanceCommand))
await adapter.create({values: {
...validCreateValues,
securityGroupIds: ['sg-11111111', 'sg-22222222'],
}})
const createCommand = commands[0] as CreateDBInstanceCommand
expect(createCommand.input.VpcSecurityGroupIds).toEqual(['sg-11111111', 'sg-22222222'])
})
test('uses defaults for blank values and omits blank database name and engine version', async () => {
const {adapter, commands} = adapterWith((command) => instanceResponse(command as CreateDBInstanceCommand))
await adapter.create({values: {
...validCreateValues,
dbInstanceClass: ' ',
allocatedStorage: '',
masterUsername: ' ',
dbName: ' ',
}})
const input = (commands[0] as CreateDBInstanceCommand).input
expect(input.DBInstanceClass).toBe('db.t3.micro')
expect(input.AllocatedStorage).toBe(20)
expect(input.MasterUsername).toBe('root')
expect(input.DBName).toBeUndefined()
expect(input.EngineVersion).toBeUndefined()
})
test('rejects invalid instance identifiers before calling RDS', async () => {
const {adapter, commands} = adapterWith(async () => ({}))
for (const dbInstanceIdentifier of ['', 'Orders-db', '1-orders', 'orders--db', 'orders-', 'a'.repeat(64)]) {
await expect(adapter.create({values: {
...validCreateValues,
dbInstanceIdentifier,
}})).rejects.toBeInstanceOf(ValidationError)
}
expect(commands).toHaveLength(0)
})
test('rejects unsupported engines before calling RDS', async () => {
const {adapter, commands} = adapterWith(async () => ({}))
for (const engine of ['', 'oracle', 'POSTGRES', 42]) {
await expect(adapter.create({values: {...validCreateValues, engine}})).rejects.toBeInstanceOf(ValidationError)
}
expect(commands).toHaveLength(0)
})
test('rejects invalid passwords without exposing them or calling RDS', async () => {
const {adapter, commands} = adapterWith(async () => ({}))
for (const masterUserPassword of ['', 'short', 'x'.repeat(129), 42]) {
let caught: unknown
try {
await adapter.create({values: {...validCreateValues, masterUserPassword}})
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(ValidationError)
const exposesPassword = caught instanceof Error
&& typeof masterUserPassword === 'string'
&& masterUserPassword.length > 0
&& caught.message.includes(masterUserPassword)
expect(exposesPassword).toBe(false)
}
expect(commands).toHaveLength(0)
})
test('rejects non-positive, fractional, and non-numeric storage before calling RDS', async () => {
const {adapter, commands} = adapterWith(async () => ({}))
for (const allocatedStorage of [0, -1, 1.5, '0', '-1', '1.5', 'twenty', true]) {
await expect(adapter.create({values: {
...validCreateValues,
allocatedStorage,
}})).rejects.toBeInstanceOf(ValidationError)
}
expect(commands).toHaveLength(0)
})
test('rejects storage that exceeds the safe integer range before calling RDS', async () => {
const {adapter, commands} = adapterWith(async () => ({}))
await expect(adapter.create({values: {
...validCreateValues,
allocatedStorage: '9'.repeat(400),
}})).rejects.toBeInstanceOf(ValidationError)
expect(commands).toHaveLength(0)
})
test('leaves provider failures for the shared AWS error mapper', async () => {
const providerError = Object.assign(new Error('instance already exists'), {
name: 'DBInstanceAlreadyExistsFault',
$fault: 'client',
$metadata: {httpStatusCode: 400},
})
const {adapter} = adapterWith(async () => {
throw providerError
})
let caught: unknown
try {
await adapter.create({values: validCreateValues})
} catch (error) {
caught = error
}
expect(caught).toBe(providerError)
expect(mapAwsSdkError(caught)).toBeInstanceOf(ConflictError)
expect(caught instanceof Error && caught.message.includes(validCreateValues.masterUserPassword)).toBe(false)
})
test('deletes without a final snapshot', async () => {
const {adapter, commands} = adapterWith(async () => ({}))
await adapter.delete('orders-db')
const deleteCommand = commands[0] as DeleteDBInstanceCommand
expect(deleteCommand).toBeInstanceOf(DeleteDBInstanceCommand)
expect(deleteCommand.input).toEqual({
DBInstanceIdentifier: 'orders-db',
SkipFinalSnapshot: true,
})
})
test('lists and normalizes database snapshots', async () => {
const {adapter, commands} = adapterWith(async (command) => {
expect(command).toBeInstanceOf(DescribeDBSnapshotsCommand)
return {
DBSnapshots: [{
DBSnapshotIdentifier: 'orders-db-snapshot-1',
DBInstanceIdentifier: 'orders-db',
DBSnapshotArn: 'arn:aws:rds:eu-west-2:000000000000:snapshot:orders-db-snapshot-1',
Status: 'available',
Engine: 'postgres',
EngineVersion: '16.4',
AllocatedStorage: 100,
SnapshotType: 'manual',
SnapshotCreateTime: new Date('2026-08-14T09:30:00.000Z'),
Port: 5432,
AvailabilityZone: 'eu-west-2a',
VpcId: 'vpc-123',
}],
}
})
const snapshots = await adapter.listDatabaseSnapshots('orders-db')
expect((commands[0] as DescribeDBSnapshotsCommand).input).toEqual({
DBInstanceIdentifier: 'orders-db',
Marker: undefined,
})
expect(snapshots).toEqual([{
id: 'orders-db-snapshot-1',
name: 'orders-db-snapshot-1',
instanceIdentifier: 'orders-db',
status: 'available',
engine: 'postgres',
version: '16.4',
createdAt: '2026-08-14T09:30:00.000Z',
metadata: {
arn: 'arn:aws:rds:eu-west-2:000000000000:snapshot:orders-db-snapshot-1',
allocatedStorage: 100,
snapshotType: 'manual',
port: 5432,
availabilityZone: 'eu-west-2a',
vpcId: 'vpc-123',
},
}])
})
test('does not swallow snapshot provider failures', async () => {
const providerError = Object.assign(new Error('operation unavailable'), {
name: 'UnsupportedOperation',
$fault: 'client',
$metadata: {httpStatusCode: 501},
})
const {adapter} = adapterWith(async () => {
throw providerError
})
await expect(adapter.listDatabaseSnapshots()).rejects.toBe(providerError)
})
test('creates and normalizes a database snapshot', async () => {
const {adapter, commands} = adapterWith(async (command) => {
expect(command).toBeInstanceOf(CreateDBSnapshotCommand)
return {
DBSnapshot: {
DBSnapshotIdentifier: 'orders-db-snapshot-1',
DBInstanceIdentifier: 'orders-db',
Status: 'creating',
Engine: 'postgres',
SnapshotType: 'manual',
},
}
})
const snapshot = await adapter.createDatabaseSnapshot({
instanceIdentifier: ' orders-db ',
snapshotIdentifier: ' orders-db-snapshot-1 ',
})
const createCommand = commands[0] as CreateDBSnapshotCommand
expect(createCommand.input).toEqual({
DBInstanceIdentifier: 'orders-db',
DBSnapshotIdentifier: 'orders-db-snapshot-1',
})
expect(snapshot).toMatchObject({
id: 'orders-db-snapshot-1',
name: 'orders-db-snapshot-1',
instanceIdentifier: 'orders-db',
status: 'creating',
engine: 'postgres',
metadata: {snapshotType: 'manual'},
})
})
test('rejects missing and invalid snapshot identifiers before calling RDS', async () => {
const {adapter, commands} = adapterWith(async () => ({}))
const invalidInputs = [
{instanceIdentifier: '', snapshotIdentifier: 'orders-db-snapshot-1'},
{instanceIdentifier: 'orders-db', snapshotIdentifier: ''},
{instanceIdentifier: 'orders db', snapshotIdentifier: 'orders-db-snapshot-1'},
{instanceIdentifier: 'orders-db', snapshotIdentifier: '1-orders-snapshot'},
{instanceIdentifier: 'orders-db', snapshotIdentifier: 'orders--snapshot'},
{instanceIdentifier: 'orders-db', snapshotIdentifier: 'orders-snapshot-'},
{instanceIdentifier: 'orders-db', snapshotIdentifier: 'a'.repeat(256)},
]
for (const input of invalidInputs) {
await expect(adapter.createDatabaseSnapshot(input)).rejects.toBeInstanceOf(ValidationError)
}
expect(commands).toHaveLength(0)
})
test('lists and deduplicates orderable instance classes', async () => {
const {adapter, commands} = adapterWith(async (command) => {
expect(command).toBeInstanceOf(DescribeOrderableDBInstanceOptionsCommand)
return {
OrderableDBInstanceOptions: [
{DBInstanceClass: 'db.t3.micro', Engine: 'postgres'},
{DBInstanceClass: 'db.m8g.large', Engine: 'postgres'},
{DBInstanceClass: 'db.t3.micro', Engine: 'postgres'},
],
}
})
const classes = await adapter.listDatabaseOrderableInstanceClasses('postgres')
expect((commands[0] as DescribeOrderableDBInstanceOptionsCommand).input).toEqual({
Engine: 'postgres',
Marker: undefined,
})
expect(classes).toEqual(['db.t3.micro', 'db.m8g.large'])
})
test('updates an instance with supported fields and excludes unsupported fields', async () => {
const {adapter, commands} = adapterWith(async (command) => {
expect(command).toBeInstanceOf(ModifyDBInstanceCommand)
return {
DBInstance: {
DBInstanceIdentifier: 'orders-db',
Engine: 'postgres',
DBInstanceStatus: 'available',
IAMDatabaseAuthenticationEnabled: false,
DBSubnetGroup: {DBSubnetGroupName: 'private-db'},
VpcSecurityGroups: [{VpcSecurityGroupId: 'sg-1', Status: 'active'}, {VpcSecurityGroupId: 'sg-2', Status: 'active'}],
OptionGroupMemberships: [{OptionGroupName: 'default-postgres', Status: 'in-sync'}],
AutoMinorVersionUpgrade: true,
},
}
})
const resource = await adapter.update('orders-db', {values: {
masterUserPassword: 'new-secret',
enableIamDatabaseAuthentication: 'false',
dbSubnetGroupName: 'private-db',
vpcSecurityGroupIds: 'sg-1, sg-2',
optionGroupName: 'default-postgres',
autoMinorVersionUpgrade: 'true',
}})
const modifyCommand = commands[0] as ModifyDBInstanceCommand
expect(modifyCommand.input).toEqual({
DBInstanceIdentifier: 'orders-db',
MasterUserPassword: 'new-secret',
EnableIAMDatabaseAuthentication: false,
DBSubnetGroupName: 'private-db',
VpcSecurityGroupIds: ['sg-1', 'sg-2'],
OptionGroupName: 'default-postgres',
AutoMinorVersionUpgrade: true,
})
expect(modifyCommand.input).not.toHaveProperty('DBInstanceClass')
expect(modifyCommand.input).not.toHaveProperty('AllocatedStorage')
expect(modifyCommand.input).not.toHaveProperty('Engine')
expect(modifyCommand.input).not.toHaveProperty('EngineVersion')
expect(modifyCommand.input).not.toHaveProperty('ApplyImmediately')
expect(JSON.stringify(resource)).not.toContain('new-secret')
expect(resource.metadata.optionGroupName).toBe('default-postgres')
expect(resource.metadata.autoMinorVersionUpgrade).toBe(true)
expect(resource.metadata.vpcSecurityGroupIds).toBe('sg-1, sg-2')
})
test('accepts boolean literals for boolean update fields', async () => {
const {adapter, commands} = adapterWith(async (command) => ({
DBInstance: {
DBInstanceIdentifier: 'orders-db',
Engine: 'postgres',
DBInstanceStatus: 'available',
},
}))
await adapter.update('orders-db', {values: {
enableIamDatabaseAuthentication: true,
autoMinorVersionUpgrade: false,
}})
const modifyCommand = commands[0] as ModifyDBInstanceCommand
expect(modifyCommand.input.EnableIAMDatabaseAuthentication).toBe(true)
expect(modifyCommand.input.AutoMinorVersionUpgrade).toBe(false)
})
test('omits blank strings from update and trims CSV', async () => {
const {adapter, commands} = adapterWith(async () => ({
DBInstance: {
DBInstanceIdentifier: 'orders-db',
Engine: 'postgres',
DBInstanceStatus: 'available',
},
}))
await adapter.update('orders-db', {values: {
masterUserPassword: '',
enableIamDatabaseAuthentication: '',
dbSubnetGroupName: ' ',
vpcSecurityGroupIds: ' sg-100 , sg-200 ',
optionGroupName: '',
autoMinorVersionUpgrade: '',
}})
const modifyCommand = commands[0] as ModifyDBInstanceCommand
expect(modifyCommand.input).toEqual({
DBInstanceIdentifier: 'orders-db',
VpcSecurityGroupIds: ['sg-100', 'sg-200'],
})
})
test('rejects malformed booleans and invalid passwords without echoing password', async () => {
const {adapter, commands} = adapterWith(async () => ({}))
await expect(adapter.update('orders-db', {values: {
enableIamDatabaseAuthentication: 'maybe',
}})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.update('orders-db', {values: {
masterUserPassword: 'short',
}})).rejects.toThrow('8 and 128')
try {
await adapter.update('orders-db', {values: {masterUserPassword: 'short'}})
} catch (err) {
expect(String(err)).not.toContain('short')
}
expect(commands).toHaveLength(0)
})
test('rejects update requests with zero effective supported changes', async () => {
const {adapter, commands} = adapterWith(async () => ({}))
await expect(adapter.update('orders-db', {values: {}})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.update('orders-db', {values: {
masterUserPassword: '',
dbSubnetGroupName: '',
unsupportedField: 'value',
}})).rejects.toBeInstanceOf(ValidationError)
expect(commands).toHaveLength(0)
})
})
@@ -0,0 +1,330 @@
import {ValidationError} from '../cloud-spi/errors'
import {ListTagsForResourceCommand, type ModifyDBInstanceCommandInput, type RDSClient} from '@aws-sdk/client-rds'
import {rds as defaultRds} from '../aws'
import {awsDatabaseSchema} from '../cloud-spi/databaseSchema'
import type {
CloudResource,
CloudServiceAdapter,
CreateDatabaseSnapshotInput,
CreateResourceInput,
DatabaseSnapshot,
ResourceQuery,
ServiceSchema,
UpdateResourceInput,
} from '../cloud-spi/types'
import {rdsService, type RdsInstance, type RdsSnapshot} from '../services/rds'
type RdsServiceShape = Pick<
typeof rdsService,
'listInstances' | 'describeInstance' | 'createInstance' | 'modifyInstance' | 'deleteInstance' | 'listSnapshots' | 'createSnapshot' | 'listOrderableInstanceClasses'
>
export class AwsDatabaseAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'database' as const
constructor(
private readonly rdsService_: RdsServiceShape = rdsService,
private readonly rds: RDSClient = defaultRds,
) {}
schema(): ServiceSchema {
return awsDatabaseSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const instances = await this.rdsService_.listInstances()
const resources = await Promise.all(instances.map((instance) => this.toResource(instance)))
return filterBySearch(resources, query.search)
}
async get(id: string): Promise<CloudResource | null> {
try {
return await this.toResource(await this.rdsService_.describeInstance(id))
} catch (error) {
if (hasHttpStatus(error, 404)) return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const values = input.values
const dbInstanceIdentifier = instanceIdentifier(values.dbInstanceIdentifier)
const engine = databaseEngine(values.engine)
const masterUserPassword = password(values.masterUserPassword)
const dbInstanceClass = stringValue(values.dbInstanceClass) || 'db.t3.micro'
const allocatedStorage = storageSize(values.allocatedStorage)
const masterUsername = stringValue(values.masterUsername) || 'root'
const dbName = stringValue(values.dbName) || undefined
const engineVersion = stringValue(values.engineVersion) || undefined
const vpcSecurityGroupIds = securityGroupIds(values.securityGroupIds ?? values.vpcSecurityGroupIds)
const instance = await this.rdsService_.createInstance({
DBInstanceIdentifier: dbInstanceIdentifier,
Engine: engine,
MasterUserPassword: masterUserPassword,
DBInstanceClass: dbInstanceClass,
AllocatedStorage: allocatedStorage,
MasterUsername: masterUsername,
DBName: dbName,
EngineVersion: engineVersion,
VpcSecurityGroupIds: vpcSecurityGroupIds,
})
return this.toResource(instance)
}
async update(id: string, input: UpdateResourceInput): Promise<CloudResource> {
const values = input.values ?? {}
const MasterUserPassword = optionalPassword(values.masterUserPassword)
const EnableIAMDatabaseAuthentication = optionalBoolean(
values.enableIamDatabaseAuthentication,
'enableIamDatabaseAuthentication',
)
const DBSubnetGroupName = optionalString(values.dbSubnetGroupName)
const VpcSecurityGroupIds = optionalCsvList(values.vpcSecurityGroupIds)
const OptionGroupName = optionalString(values.optionGroupName)
const AutoMinorVersionUpgrade = optionalBoolean(
values.autoMinorVersionUpgrade,
'autoMinorVersionUpgrade',
)
const hasAnyUpdate =
MasterUserPassword !== undefined ||
EnableIAMDatabaseAuthentication !== undefined ||
DBSubnetGroupName !== undefined ||
VpcSecurityGroupIds !== undefined ||
OptionGroupName !== undefined ||
AutoMinorVersionUpgrade !== undefined
if (!hasAnyUpdate) {
throw new ValidationError('At least one supported field must be updated')
}
const modifyInput: ModifyDBInstanceCommandInput = {
DBInstanceIdentifier: id,
...(MasterUserPassword !== undefined && {MasterUserPassword}),
...(EnableIAMDatabaseAuthentication !== undefined && {EnableIAMDatabaseAuthentication}),
...(DBSubnetGroupName !== undefined && {DBSubnetGroupName}),
...(VpcSecurityGroupIds !== undefined && {VpcSecurityGroupIds}),
...(OptionGroupName !== undefined && {OptionGroupName}),
...(AutoMinorVersionUpgrade !== undefined && {AutoMinorVersionUpgrade}),
}
const updated = await this.rdsService_.modifyInstance(modifyInput)
return this.toResource(updated)
}
async delete(id: string): Promise<void> {
await this.rdsService_.deleteInstance(id)
}
async listDatabaseSnapshots(instanceIdentifier?: string): Promise<DatabaseSnapshot[]> {
const normalizedIdentifier = stringValue(instanceIdentifier) || undefined
return (await this.rdsService_.listSnapshots(normalizedIdentifier)).map(snapshotToDatabaseSnapshot)
}
async createDatabaseSnapshot(input: CreateDatabaseSnapshotInput): Promise<DatabaseSnapshot> {
const sourceIdentifier = instanceIdentifier(input.instanceIdentifier)
const snapshotIdentifier = databaseSnapshotIdentifier(input.snapshotIdentifier)
return snapshotToDatabaseSnapshot(
await this.rdsService_.createSnapshot(sourceIdentifier, snapshotIdentifier),
)
}
async listDatabaseOrderableInstanceClasses(engine?: string): Promise<string[]> {
const normalizedEngine = stringValue(engine) || 'postgres'
return await this.rdsService_.listOrderableInstanceClasses(normalizedEngine)
}
private async toResource(instance: RdsInstance): Promise<CloudResource> {
const tags = instance.arn ? await this.getTags(instance.arn) : []
const vpcSecurityGroupIds = (instance.vpcSecurityGroups ?? [])
.map((group) => group.id)
.filter((id): id is string => Boolean(id))
.join(', ')
return {
id: instance.identifier,
name: instance.identifier,
cloud: 'aws',
service: 'database',
type: 'db-instance',
region: instance.availabilityZone ?? null,
createdAt: instance.createdAt ?? null,
status: instance.status ?? null,
version: instance.engineVersion ?? null,
engine: instance.engine ?? null,
instanceClass: instance.instanceClass ?? null,
metadata: {
arn: instance.arn,
resourceId: instance.resourceId,
dbName: instance.dbName,
masterUsername: instance.masterUsername,
allocatedStorage: instance.allocatedStorage,
storageType: instance.storageType,
endpoint: instance.endpoint,
multiAz: instance.multiAz,
publiclyAccessible: instance.publiclyAccessible,
iamDatabaseAuthenticationEnabled: instance.iamDatabaseAuthenticationEnabled,
preferredBackupWindow: instance.preferredBackupWindow,
preferredMaintenanceWindow: instance.preferredMaintenanceWindow,
vpcSecurityGroups: instance.vpcSecurityGroups,
vpcSecurityGroupIds,
subnetGroup: instance.subnetGroup,
optionGroupName: instance.optionGroupName,
autoMinorVersionUpgrade: instance.autoMinorVersionUpgrade,
tags,
},
}
}
private async getTags(arn: string): Promise<Array<{key: string; value: string}>> {
try {
const res = await this.rds.send(new ListTagsForResourceCommand({ResourceName: arn}))
return (res.TagList ?? []).map((tag) => ({
key: tag.Key ?? '',
value: tag.Value ?? '',
}))
} catch (error) {
if (error instanceof Error && error.message.includes('ListTagsForResource is not supported')) return []
throw error
}
}
}
const INSTANCE_IDENTIFIER_PATTERN = /^(?!.*--)[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/
const SNAPSHOT_IDENTIFIER_PATTERN = /^(?!.*--)[A-Za-z](?:[A-Za-z0-9-]{0,253}[A-Za-z0-9])?$/
function instanceIdentifier(value: unknown): string {
const identifier = stringValue(value)
if (!INSTANCE_IDENTIFIER_PATTERN.test(identifier)) {
throw new ValidationError('dbInstanceIdentifier must be 1-63 lowercase letters, numbers, or hyphens, begin with a letter, and not end with or repeat a hyphen')
}
return identifier
}
function databaseSnapshotIdentifier(value: unknown): string {
const identifier = stringValue(value)
if (!SNAPSHOT_IDENTIFIER_PATTERN.test(identifier)) {
throw new ValidationError('snapshotIdentifier must be 1-255 letters, numbers, or hyphens, begin with a letter, and not end with or repeat a hyphen')
}
return identifier
}
function databaseEngine(value: unknown): 'postgres' | 'mysql' | 'mariadb' {
const engine = stringValue(value)
if (engine === 'postgres' || engine === 'mysql' || engine === 'mariadb') return engine
throw new ValidationError('engine must be postgres, mysql, or mariadb')
}
function password(value: unknown): string {
if (typeof value !== 'string' || value.length < 8 || value.length > 128) {
throw new ValidationError('masterUserPassword must be 8-128 characters')
}
return value
}
function storageSize(value: unknown): number {
if (value === undefined || (typeof value === 'string' && !value.trim())) return 20
const normalized = typeof value === 'string' ? value.trim() : value
const hasValidSyntax = typeof normalized === 'number'
|| (typeof normalized === 'string' && /^[1-9][0-9]*$/.test(normalized))
if (!hasValidSyntax) {
throw new ValidationError('allocatedStorage must be a positive integer')
}
const storage = Number(normalized)
if (!Number.isSafeInteger(storage) || storage <= 0) {
throw new ValidationError('allocatedStorage must be a positive integer')
}
return storage
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function securityGroupIds(value: unknown): string[] | undefined {
if (Array.isArray(value)) {
const list = value.map(stringValue).filter(Boolean)
return list.length > 0 ? list : undefined
}
if (typeof value === 'string') {
const list = value.split(',').map((s) => s.trim()).filter(Boolean)
return list.length > 0 ? list : undefined
}
return undefined
}
function snapshotToDatabaseSnapshot(snapshot: RdsSnapshot): DatabaseSnapshot {
return {
id: snapshot.identifier,
name: snapshot.identifier,
instanceIdentifier: snapshot.instanceIdentifier ?? null,
status: snapshot.status ?? null,
engine: snapshot.engine ?? null,
version: snapshot.engineVersion ?? null,
createdAt: snapshot.createdAt ?? null,
metadata: {
arn: snapshot.arn,
allocatedStorage: snapshot.allocatedStorage,
snapshotType: snapshot.snapshotType,
port: snapshot.port,
availabilityZone: snapshot.availabilityZone,
vpcId: snapshot.vpcId,
},
}
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
}
function hasHttpStatus(error: unknown, status: number): boolean {
if (typeof error !== 'object' || error === null) return false
const metadata = (error as {$metadata?: {httpStatusCode?: number}}).$metadata
return metadata?.httpStatusCode === status
}
function optionalPassword(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined
const str = String(value)
if (str === '') return undefined
if (str.length < 8 || str.length > 128) {
throw new ValidationError('masterUserPassword must be between 8 and 128 characters')
}
return str
}
function optionalBoolean(value: unknown, fieldName: string): boolean | undefined {
if (value === undefined || value === null) return undefined
if (typeof value === 'boolean') return value
if (typeof value === 'string') {
const trimmed = value.trim()
if (trimmed === '') return undefined
if (trimmed === 'true') return true
if (trimmed === 'false') return false
}
throw new ValidationError(`${fieldName} must be a boolean (true or false)`)
}
function optionalString(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined
const trimmed = String(value).trim()
return trimmed === '' ? undefined : trimmed
}
function optionalCsvList(value: unknown): string[] | undefined {
if (value === undefined || value === null) return undefined
if (Array.isArray(value)) {
const items = value.map((v) => String(v).trim()).filter(Boolean)
return items.length > 0 ? items : undefined
}
const str = String(value).trim()
if (!str) return undefined
const items = str.split(',').map((s) => s.trim()).filter(Boolean)
return items.length > 0 ? items : undefined
}
@@ -0,0 +1,469 @@
import {describe, expect, test} from 'bun:test'
import {
CreateTableCommand,
DeleteTableCommand,
DescribeTableCommand,
ListTablesCommand,
PutItemCommand,
ScanCommand,
type DynamoDBClient,
} from '@aws-sdk/client-dynamodb'
import {AwsDynamoDbAdapter} from './AwsDynamoDbAdapter'
import {ValidationError} from '../cloud-spi/errors'
type Command = CreateTableCommand | DeleteTableCommand | DescribeTableCommand | ListTablesCommand | PutItemCommand | ScanCommand
function fakeClient(send: (command: Command) => Promise<unknown>): DynamoDBClient {
return {send} as unknown as DynamoDBClient
}
describe('AwsDynamoDbAdapter', () => {
test('lists, describes, maps, and filters tables across pages', async () => {
const describedTables: string[] = []
const client = fakeClient(async (command) => {
if (command instanceof ListTablesCommand) {
return command.input.ExclusiveStartTableName
? {TableNames: ['audit-events']}
: {TableNames: ['orders'], LastEvaluatedTableName: 'orders'}
}
if (command instanceof DescribeTableCommand) {
describedTables.push(command.input.TableName ?? '')
return {Table: {
TableName: command.input.TableName,
TableStatus: 'ACTIVE',
TableArn: `arn:aws:dynamodb:eu-west-2:000000000000:table/${command.input.TableName}`,
CreationDateTime: new Date('2026-07-18T10:00:00.000Z'),
ItemCount: 3,
KeySchema: [{AttributeName: 'pk', KeyType: 'HASH'}],
}}
}
throw new Error('Unexpected command')
})
const result = await new AwsDynamoDbAdapter(client).list({search: 'audit'})
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
id: 'audit-events',
name: 'audit-events',
cloud: 'aws',
service: 'nosql',
type: 'dynamodb-table',
region: 'eu-west-2',
status: 'ACTIVE',
createdAt: '2026-07-18T10:00:00.000Z',
})
expect(result[0].metadata.itemCount).toBe(3)
expect(describedTables).toEqual(['audit-events'])
})
test('describes tables in bounded batches', async () => {
let activeDescriptions = 0
let maximumActiveDescriptions = 0
const tableNames = Array.from({length: 21}, (_, index) => `table-${index}`)
const client = fakeClient(async (command) => {
if (command instanceof ListTablesCommand) return {TableNames: tableNames}
if (command instanceof DescribeTableCommand) {
activeDescriptions += 1
maximumActiveDescriptions = Math.max(maximumActiveDescriptions, activeDescriptions)
await Promise.resolve()
activeDescriptions -= 1
return {Table: {TableName: command.input.TableName}}
}
throw new Error('Unexpected command')
})
const result = await new AwsDynamoDbAdapter(client).list()
expect(result).toHaveLength(21)
expect(maximumActiveDescriptions).toBe(10)
})
test('gets a table description', async () => {
const client = fakeClient(async (command) => {
expect(command).toBeInstanceOf(DescribeTableCommand)
return {Table: {TableName: 'orders', TableStatus: 'ACTIVE'}}
})
const result = await new AwsDynamoDbAdapter(client).get('orders')
expect(result?.name).toBe('orders')
})
test('returns null when a table does not exist', async () => {
const client = fakeClient(async () => {
throw Object.assign(new Error('Requested resource not found'), {name: 'ResourceNotFoundException'})
})
await expect(new AwsDynamoDbAdapter(client).get('missing')).resolves.toBeNull()
})
test('creates a table with partition and sort keys', async () => {
let captured: CreateTableCommand | undefined
const client = fakeClient(async (command) => {
expect(command).toBeInstanceOf(CreateTableCommand)
captured = command as CreateTableCommand
return {TableDescription: {TableName: 'orders', TableStatus: 'ACTIVE'}}
})
const result = await new AwsDynamoDbAdapter(client).create({values: {
tableName: 'orders',
partitionKey: 'accountId',
partitionKeyType: 'S',
sortKey: 'createdAt',
sortKeyType: 'N',
}})
expect(captured?.input).toEqual({
TableName: 'orders',
AttributeDefinitions: [
{AttributeName: 'accountId', AttributeType: 'S'},
{AttributeName: 'createdAt', AttributeType: 'N'},
],
KeySchema: [
{AttributeName: 'accountId', KeyType: 'HASH'},
{AttributeName: 'createdAt', KeyType: 'RANGE'},
],
BillingMode: 'PAY_PER_REQUEST',
})
expect(result).toMatchObject({name: 'orders', type: 'dynamodb-table'})
})
test('reports no status when create returns no table description', async () => {
const client = fakeClient(async () => ({}))
const result = await new AwsDynamoDbAdapter(client).create({values: {
tableName: 'orders',
partitionKey: 'accountId',
partitionKeyType: 'S',
}})
expect(result).toMatchObject({name: 'orders', status: null})
})
test('defaults an optional sort key type to string', async () => {
let captured: CreateTableCommand | undefined
const client = fakeClient(async (command) => {
captured = command as CreateTableCommand
return {TableDescription: {TableName: 'orders'}}
})
await new AwsDynamoDbAdapter(client).create({values: {
tableName: 'orders',
partitionKey: 'pk',
partitionKeyType: 'S',
sortKey: 'sk',
}})
expect(captured?.input.AttributeDefinitions?.[1]?.AttributeType).toBe('S')
})
test('validates create input before calling DynamoDB', async () => {
let called = false
const client = fakeClient(async () => {
called = true
return {}
})
const adapter = new AwsDynamoDbAdapter(client)
await expect(adapter.create({values: {
tableName: 'x',
partitionKey: 'pk',
partitionKeyType: 'S',
}})).rejects.toThrow('Use a valid DynamoDB table name')
await expect(adapter.create({values: {
tableName: 'orders',
partitionKey: 'pk',
partitionKeyType: 'S',
sortKey: 'pk',
}})).rejects.toThrow('sortKey must differ from partitionKey')
expect(called).toBe(false)
})
test('deletes a table', async () => {
let captured: DeleteTableCommand | undefined
const client = fakeClient(async (command) => {
captured = command as DeleteTableCommand
return {}
})
await new AwsDynamoDbAdapter(client).delete('orders')
expect(captured?.input.TableName).toBe('orders')
})
test('lists and unmarshalls table records across scan pages', async () => {
const client = fakeClient(async (command) => {
if (command instanceof DescribeTableCommand) {
return {Table: {KeySchema: [
{AttributeName: 'accountId', KeyType: 'HASH'},
{AttributeName: 'createdAt', KeyType: 'RANGE'},
]}}
}
if (command instanceof ScanCommand) {
expect(command.input.Limit).toBe(command.input.ExclusiveStartKey ? 99 : 100)
return command.input.ExclusiveStartKey
? {Items: [{accountId: {S: 'acct-2'}, createdAt: {N: '9007199254740993'}, active: {BOOL: false}, roles: {SS: ['admin', 'editor']}}]}
: {
Items: [{accountId: {S: 'acct-1'}, createdAt: {N: '1'}, active: {BOOL: true}}],
LastEvaluatedKey: {accountId: {S: 'acct-1'}, createdAt: {N: '1'}},
}
}
throw new Error('Unexpected command')
})
const result = await new AwsDynamoDbAdapter(client).listNoSqlItems('orders')
expect(result).toEqual([
{
id: '{"accountId":"acct-1","createdAt":1}',
key: {accountId: 'acct-1', createdAt: 1},
document: {accountId: 'acct-1', createdAt: 1, active: true},
},
{
id: '{"accountId":"acct-2","createdAt":"9007199254740993"}',
key: {accountId: 'acct-2', createdAt: '9007199254740993'},
document: {accountId: 'acct-2', createdAt: '9007199254740993', active: false, roles: ['admin', 'editor']},
},
])
})
test('stops scanning after 100 records', async () => {
let scanCount = 0
const client = fakeClient(async (command) => {
if (command instanceof DescribeTableCommand) {
return {Table: {KeySchema: [{AttributeName: 'id', KeyType: 'HASH'}]}}
}
if (command instanceof ScanCommand) {
scanCount += 1
expect(command.input.Limit).toBe(100)
return {
Items: Array.from({length: 100}, (_, index) => ({id: {N: String(index)}})),
LastEvaluatedKey: {id: {N: '99'}},
}
}
throw new Error('Unexpected command')
})
const result = await new AwsDynamoDbAdapter(client).listNoSqlItems('orders')
expect(result).toHaveLength(100)
expect(scanCount).toBe(1)
})
test('puts a record using the table key types', async () => {
let captured: PutItemCommand | undefined
const client = fakeClient(async (command) => {
if (command instanceof DescribeTableCommand) {
return {Table: {
KeySchema: [
{AttributeName: 'accountId', KeyType: 'HASH'},
{AttributeName: 'sequence', KeyType: 'RANGE'},
],
AttributeDefinitions: [
{AttributeName: 'accountId', AttributeType: 'S'},
{AttributeName: 'sequence', AttributeType: 'N'},
],
}}
}
if (command instanceof PutItemCommand) {
captured = command
return {}
}
throw new Error('Unexpected command')
})
const result = await new AwsDynamoDbAdapter(client).putNoSqlItem('orders', {
accountId: 'acct-1',
sequence: '9007199254740993',
active: true,
})
expect(captured?.input).toEqual({
TableName: 'orders',
Item: {
accountId: {S: 'acct-1'},
sequence: {N: '9007199254740993'},
active: {BOOL: true},
},
})
expect(result).toEqual({
id: '{"accountId":"acct-1","sequence":"9007199254740993"}',
key: {accountId: 'acct-1', sequence: '9007199254740993'},
document: {accountId: 'acct-1', sequence: '9007199254740993', active: true},
})
})
test('accepts base64 for binary keys', async () => {
let captured: PutItemCommand | undefined
const client = fakeClient(async (command) => {
if (command instanceof DescribeTableCommand) {
return {Table: {
KeySchema: [{AttributeName: 'id', KeyType: 'HASH'}],
AttributeDefinitions: [{AttributeName: 'id', AttributeType: 'B'}],
}}
}
captured = command as PutItemCommand
return {}
})
const result = await new AwsDynamoDbAdapter(client).putNoSqlItem('blobs', {id: 'aGVsbG8=', name: 'hello'})
expect(captured?.input.Item?.id.B).toEqual(Buffer.from('hello'))
expect(result.key).toEqual({id: 'aGVsbG8='})
})
test('validates required and typed keys before putting a record', async () => {
let putCalls = 0
const client = fakeClient(async (command) => {
if (command instanceof DescribeTableCommand) {
return {Table: {
KeySchema: [{AttributeName: 'id', KeyType: 'HASH'}],
AttributeDefinitions: [{AttributeName: 'id', AttributeType: 'N'}],
}}
}
putCalls += 1
return {}
})
const adapter = new AwsDynamoDbAdapter(client)
await expect(adapter.putNoSqlItem('orders', {})).rejects.toThrow('Key attribute id is required')
await expect(adapter.putNoSqlItem('orders', {id: 'not-a-number'})).rejects.toThrow('Key attribute id must be a number')
for (const json of ['1', '1.5', '9007199254740993', '123456789.123456789', '1.0000000000000000001']) {
await expect(adapter.putNoSqlItem('orders', {id: JSON.parse(json)}))
.rejects.toThrow('must be a quoted number to preserve precision')
}
expect(putCalls).toBe(0)
})
test.each(['123456789.123456789', '1.0000000000000000001'])('preserves the quoted numeric key %s when writing and reading', async (id) => {
let captured: PutItemCommand | undefined
const client = fakeClient(async (command) => {
if (command instanceof DescribeTableCommand) {
return {Table: {
KeySchema: [{AttributeName: 'id', KeyType: 'HASH'}],
AttributeDefinitions: [{AttributeName: 'id', AttributeType: 'N'}],
}}
}
if (command instanceof ScanCommand) return {Items: [{id: {N: id}}]}
captured = command as PutItemCommand
return {}
})
const adapter = new AwsDynamoDbAdapter(client)
const created = await adapter.putNoSqlItem('orders', {id})
const listed = await adapter.listNoSqlItems('orders')
expect(captured?.input.Item?.id).toEqual({N: id})
expect(created).toEqual({id: JSON.stringify({id}), key: {id}, document: {id}})
expect(listed).toEqual([created])
})
test.each([
['top-level attribute', '{"id":"order-1","count":9007199254740993}', '$["count"]'],
['nested object', '{"id":"order-1","details":{"count":-9007199254740993}}', '$["details"]["count"]'],
['array value', '{"id":"order-1","counts":[0,9007199254740993]}', '$["counts"][1]'],
['object inside an array', '{"id":"order-1","entries":[{"count":9007199254740993}]}', '$["entries"][0]["count"]'],
])('rejects unsafe integers (%s) before putting a record', async (_name, json, path) => {
let putCalls = 0
const client = fakeClient(async (command) => {
if (command instanceof DescribeTableCommand) {
return {Table: {
KeySchema: [{AttributeName: 'id', KeyType: 'HASH'}],
AttributeDefinitions: [{AttributeName: 'id', AttributeType: 'S'}],
}}
}
putCalls += 1
return {}
})
await expect(new AwsDynamoDbAdapter(client).putNoSqlItem('orders', JSON.parse(json)))
.rejects.toThrow(new ValidationError(`Item value at ${path} must quote integers outside JavaScript's safe range.`))
expect(putCalls).toBe(0)
})
test.each([
['1.0', '1', 1],
['1e3', '1000', 1000],
['1000', '1E+3', 1000],
['+0001.5000', '1.5', 1.5],
['-0.00e10', '0', 0],
['.00000100', '1E-6', 0.000001],
['0.0000001', '1E-7', 1e-7],
['9007199254740993.00', '9.007199254740993E15', '9007199254740993'],
['-9007199254740993e0', '-9007199254740993', '-9007199254740993'],
['1.000000000000000000100', '10000000000000000001E-19', '1.0000000000000000001'],
['123456789.1234567890', '1.23456789123456789E8', '123456789.123456789'],
['10e-131', '1E-130', 1e-130],
['1e125', '10E124', '1e125'],
] as const)('keeps create and refreshed IDs equal for %s and %s', async (input, stored, normalized) => {
const client = fakeClient(async (command) => {
if (command instanceof DescribeTableCommand) {
return {Table: {
KeySchema: [
{AttributeName: 'accountId', KeyType: 'HASH'},
{AttributeName: 'sequence', KeyType: 'RANGE'},
],
AttributeDefinitions: [
{AttributeName: 'accountId', AttributeType: 'S'},
{AttributeName: 'sequence', AttributeType: 'N'},
],
}}
}
if (command instanceof PutItemCommand) {
expect(command.input.Item?.sequence).toEqual({N: input})
return {}
}
if (command instanceof ScanCommand) {
return {Items: [{accountId: {S: 'acct-1'}, sequence: {N: stored}}]}
}
throw new Error('Unexpected command')
})
const adapter = new AwsDynamoDbAdapter(client)
const created = await adapter.putNoSqlItem('orders', {accountId: 'acct-1', sequence: input})
const listed = await adapter.listNoSqlItems('orders')
const key = {accountId: 'acct-1', sequence: normalized}
expect(created).toEqual({id: JSON.stringify(key), key, document: key})
expect(listed).toEqual([created])
})
test('preserves safe integers, fractions, and quoted large values in nested records', async () => {
let captured: PutItemCommand | undefined
const client = fakeClient(async (command) => {
if (command instanceof DescribeTableCommand) {
return {Table: {
KeySchema: [{AttributeName: 'id', KeyType: 'HASH'}],
AttributeDefinitions: [{AttributeName: 'id', AttributeType: 'S'}],
}}
}
captured = command as PutItemCommand
return {}
})
const document = {
id: 'order-1',
values: [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, 0, 1.5],
details: {count: '9007199254740993', active: true, optional: null},
}
const result = await new AwsDynamoDbAdapter(client).putNoSqlItem('orders', document)
expect(captured?.input.Item?.values).toEqual({L: [
{N: '-9007199254740991'}, {N: '9007199254740991'}, {N: '0'}, {N: '1.5'},
]})
expect(captured?.input.Item?.details.M?.count).toEqual({S: '9007199254740993'})
expect(result.document).toEqual(document)
})
test('returns the AWS DynamoDB schema', () => {
const adapter = new AwsDynamoDbAdapter(fakeClient(async () => ({})))
expect(adapter.schema()).toMatchObject({
cloud: 'aws',
service: 'nosql',
displayName: 'DynamoDB',
actions: ['list', 'create', 'delete', 'inspect'],
})
})
})
@@ -0,0 +1,359 @@
import {
CreateTableCommand,
DeleteTableCommand,
DescribeTableCommand,
ListTablesCommand,
PutItemCommand,
ScanCommand,
type AttributeDefinition,
type AttributeValue,
type DynamoDBClient,
type KeySchemaElement,
type ScalarAttributeType,
type TableDescription,
} from '@aws-sdk/client-dynamodb'
import {marshall, unmarshall} from '@aws-sdk/util-dynamodb'
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import {dynamodb as defaultDynamoDb} from '../aws'
import {awsDynamoDbSchema} from '../cloud-spi/dynamodbSchema'
import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
NoSqlItem,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
const DESCRIBE_BATCH_SIZE = 10
const ITEM_SCAN_LIMIT = 100
export class AwsDynamoDbAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'nosql' as const
constructor(private readonly dynamodb: DynamoDBClient = defaultDynamoDb) {}
schema(): ServiceSchema {
return awsDynamoDbSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const tableNames = filterNamesBySearch(await this.listTableNames(), query.search)
const resources: CloudResource[] = []
for (let index = 0; index < tableNames.length; index += DESCRIBE_BATCH_SIZE) {
const batch = tableNames.slice(index, index + DESCRIBE_BATCH_SIZE)
resources.push(...await Promise.all(batch.map((tableName) => this.describe(tableName))))
}
return resources
}
async get(id: string): Promise<CloudResource | null> {
try {
return await this.describe(id)
} catch (error) {
if (isResourceNotFound(error)) return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const tableName = requiredString(input.values.tableName, 'tableName')
if (!isValidTableName(tableName)) {
throw new ValidationError('Use a valid DynamoDB table name: 3-255 letters, numbers, underscores, dots, or hyphens.')
}
const partitionKey = requiredString(input.values.partitionKey, 'partitionKey')
const partitionKeyType = attributeType(input.values.partitionKeyType, 'partitionKeyType')
const sortKey = stringValue(input.values.sortKey)
if (sortKey === partitionKey) throw new ValidationError('sortKey must differ from partitionKey')
const attributeDefinitions: AttributeDefinition[] = [
{AttributeName: partitionKey, AttributeType: partitionKeyType},
]
const keySchema: KeySchemaElement[] = [
{AttributeName: partitionKey, KeyType: 'HASH'},
]
if (sortKey) {
attributeDefinitions.push({
AttributeName: sortKey,
AttributeType: attributeType(input.values.sortKeyType || 'S', 'sortKeyType'),
})
keySchema.push({AttributeName: sortKey, KeyType: 'RANGE'})
}
const response = await this.dynamodb.send(new CreateTableCommand({
TableName: tableName,
AttributeDefinitions: attributeDefinitions,
KeySchema: keySchema,
BillingMode: 'PAY_PER_REQUEST',
}))
return toResource(response.TableDescription ?? {
TableName: tableName,
AttributeDefinitions: attributeDefinitions,
KeySchema: keySchema,
BillingModeSummary: {BillingMode: 'PAY_PER_REQUEST'},
})
}
async delete(id: string): Promise<void> {
await this.dynamodb.send(new DeleteTableCommand({TableName: id}))
}
async listNoSqlItems(resourceId: string): Promise<NoSqlItem[]> {
const table = await this.describeTable(resourceId)
const keyNames = keyAttributes(table).map(({name}) => name)
const items: NoSqlItem[] = []
let exclusiveStartKey: Record<string, AttributeValue> | undefined
do {
const response = await this.dynamodb.send(new ScanCommand({
TableName: resourceId,
ExclusiveStartKey: exclusiveStartKey,
Limit: ITEM_SCAN_LIMIT - items.length,
}))
for (const rawItem of response.Items ?? []) {
const document = normalizeDocument(unmarshall(rawItem, {wrapNumbers: normalizeNumber}))
const key = Object.fromEntries(keyNames.map((name) => [name, document[name]]))
items.push({id: JSON.stringify(key), key, document})
if (items.length === ITEM_SCAN_LIMIT) break
}
exclusiveStartKey = response.LastEvaluatedKey
} while (items.length < ITEM_SCAN_LIMIT && exclusiveStartKey && Object.keys(exclusiveStartKey).length > 0)
return items
}
async putNoSqlItem(resourceId: string, document: Record<string, unknown>): Promise<NoSqlItem> {
if (!document || typeof document !== 'object' || Array.isArray(document)) {
throw new ValidationError('Item must be a JSON object.')
}
const table = await this.describeTable(resourceId)
const keys = keyAttributes(table)
if (keys.length === 0) throw new RuntimeError(`DynamoDB table ${resourceId} has no key schema`)
const untypedKey = keys.find(({type}) => !type)
if (untypedKey) throw new RuntimeError(`DynamoDB table ${resourceId} has no type definition for key ${untypedKey.name}`)
const item = marshalDocument(document, keys)
await this.dynamodb.send(new PutItemCommand({TableName: resourceId, Item: item}))
const normalizedDocument = normalizeDocument(unmarshall(item, {wrapNumbers: normalizeNumber}))
const key = Object.fromEntries(keys.map(({name}) => [name, normalizedDocument[name]]))
return {id: JSON.stringify(key), key, document: normalizedDocument}
}
private async listTableNames(): Promise<string[]> {
const tableNames: string[] = []
let exclusiveStartTableName: string | undefined
do {
const response = await this.dynamodb.send(new ListTablesCommand({
ExclusiveStartTableName: exclusiveStartTableName,
}))
tableNames.push(...(response.TableNames ?? []))
exclusiveStartTableName = response.LastEvaluatedTableName
} while (exclusiveStartTableName)
return tableNames
}
private async describe(tableName: string): Promise<CloudResource> {
return toResource(await this.describeTable(tableName))
}
private async describeTable(tableName: string): Promise<TableDescription> {
const response = await this.dynamodb.send(new DescribeTableCommand({TableName: tableName}))
if (!response.Table) throw new RuntimeError(`DynamoDB did not return a description for table ${tableName}`)
return response.Table
}
}
interface KeyAttribute {
name: string
type?: ScalarAttributeType
}
function keyAttributes(table: TableDescription): KeyAttribute[] {
const attributeTypes = new Map(
(table.AttributeDefinitions ?? [])
.filter((definition): definition is AttributeDefinition & {AttributeName: string} => Boolean(definition.AttributeName))
.map((definition) => [definition.AttributeName, definition.AttributeType]),
)
return (table.KeySchema ?? [])
.filter((key): key is KeySchemaElement & {AttributeName: string} => Boolean(key.AttributeName))
.map((key) => ({name: key.AttributeName, type: attributeTypes.get(key.AttributeName)}))
}
function marshalDocument(document: Record<string, unknown>, keys: KeyAttribute[]): Record<string, AttributeValue> {
for (const {name} of keys) {
if (!Object.prototype.hasOwnProperty.call(document, name)) {
throw new ValidationError(`Key attribute ${name} is required.`)
}
}
const marshalledKeys = Object.fromEntries(
keys.map((key) => [key.name, marshalKey(key, document[key.name])]),
)
validateSafeIntegers(document)
let item: Record<string, AttributeValue>
try {
item = marshall(document)
} catch (error) {
throw new ValidationError(error instanceof Error ? error.message : 'Item contains a value DynamoDB cannot store.', {cause: error})
}
return {...item, ...marshalledKeys}
}
function validateSafeIntegers(value: unknown, path = '$'): void {
if (typeof value === 'number' && Number.isInteger(value) && !Number.isSafeInteger(value)) {
throw new ValidationError(`Item value at ${path} must quote integers outside JavaScript's safe range.`)
}
if (Array.isArray(value)) {
value.forEach((item, index) => validateSafeIntegers(item, `${path}[${index}]`))
} else if (typeof value === 'object' && value !== null) {
for (const [name, item] of Object.entries(value)) {
validateSafeIntegers(item, `${path}[${JSON.stringify(name)}]`)
}
}
}
function marshalKey(key: KeyAttribute, value: unknown): AttributeValue {
if (key.type === 'N') {
if (typeof value === 'number') {
throw new ValidationError(`Key attribute ${key.name} must be a quoted number to preserve precision.`)
}
const number = typeof value === 'string' ? value : ''
if (!isDynamoNumber(number)) throw new ValidationError(`Key attribute ${key.name} must be a number.`)
return {N: number}
}
if (key.type === 'B') {
if (typeof value !== 'string' || !isBase64(value)) {
throw new ValidationError(`Binary key attribute ${key.name} must be a non-empty base64 string.`)
}
return {B: Buffer.from(value, 'base64')}
}
if (typeof value !== 'string' || !value) throw new ValidationError(`Key attribute ${key.name} must be a non-empty string.`)
return {S: value}
}
function isDynamoNumber(value: string): boolean {
return /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[Ee][+-]?\d+)?$/.test(value)
}
function isBase64(value: string): boolean {
return value.length > 0 && value.length % 4 === 0 && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)
}
function normalizeDocument(document: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(document).map(([name, value]) => [name, normalizeValue(value)]),
)
}
function normalizeValue(value: unknown): unknown {
if (typeof value === 'bigint') return value.toString()
if (value instanceof Uint8Array) return Buffer.from(value).toString('base64')
if (value instanceof Set) return Array.from(value, normalizeValue)
if (Array.isArray(value)) return value.map(normalizeValue)
if (typeof value === 'object' && value !== null) return normalizeDocument(value as Record<string, unknown>)
return value
}
// Compare exact decimals so DynamoDB's formatting changes cannot change item IDs.
function normalizeNumber(value: string): number | string {
const canonical = canonicalDecimal(value)
const parsed = Number(canonical)
if (!Number.isFinite(parsed) || (Number.isInteger(parsed) && !Number.isSafeInteger(parsed))) return canonical
return canonicalDecimal(String(parsed)) === canonical ? parsed : canonical
}
function canonicalDecimal(value: string): string {
const [coefficient, exponent = '0'] = value.toLowerCase().split('e')
const sign = coefficient.startsWith('-') ? '-' : ''
const [integer, fraction = ''] = coefficient.replace(/^[+-]/, '').split('.')
const digits = (integer + fraction).replace(/^0+/, '')
if (!digits) return '0'
const point = BigInt(digits.length - fraction.length) + BigInt(exponent)
const significant = digits.replace(/0+$/, '')
if (point <= -6n || point > 21n) {
const tail = significant.slice(1)
const mantissa = significant[0] + (tail ? `.${tail}` : '')
return `${sign}${mantissa}e${point - 1n}`
}
const position = Number(point)
if (position <= 0) return `${sign}0.${'0'.repeat(-position)}${significant}`
if (position >= significant.length) return sign + significant.padEnd(position, '0')
return `${sign}${significant.slice(0, position)}.${significant.slice(position)}`
}
function toResource(table: TableDescription): CloudResource {
const name = table.TableName ?? ''
return {
id: name,
name,
cloud: 'aws',
service: 'nosql',
type: 'dynamodb-table',
region: regionFromArn(table.TableArn),
createdAt: table.CreationDateTime?.toISOString() ?? null,
status: table.TableStatus ?? null,
metadata: {
arn: table.TableArn,
tableId: table.TableId,
itemCount: table.ItemCount,
tableSizeBytes: table.TableSizeBytes,
billingMode: table.BillingModeSummary?.BillingMode,
keySchema: table.KeySchema,
attributeDefinitions: table.AttributeDefinitions,
provisionedThroughput: table.ProvisionedThroughput,
globalSecondaryIndexes: table.GlobalSecondaryIndexes,
localSecondaryIndexes: table.LocalSecondaryIndexes,
streamSpecification: table.StreamSpecification,
deletionProtectionEnabled: table.DeletionProtectionEnabled,
},
}
}
function requiredString(value: unknown, field: string): string {
const result = stringValue(value)
if (!result) throw new ValidationError(`${field} is required`)
return result
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function attributeType(value: unknown, field: string): ScalarAttributeType {
const result = stringValue(value)
if (result === 'S' || result === 'N' || result === 'B') return result
throw new ValidationError(`${field} must be S, N, or B`)
}
function filterNamesBySearch(names: string[], search?: string): string[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return names
return names.filter((name) => name.toLowerCase().includes(normalized))
}
function regionFromArn(arn?: string): string | null {
return arn?.split(':')[3] || null
}
function isValidTableName(value: string): boolean {
return /^[A-Za-z0-9_.-]{3,255}$/.test(value)
}
function isResourceNotFound(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false
const candidate = error as {name?: string; $metadata?: {httpStatusCode?: number}}
return candidate.name === 'ResourceNotFoundException' || candidate.$metadata?.httpStatusCode === 404
}
@@ -0,0 +1,67 @@
import {describe, expect, test} from 'bun:test'
import {AwsEksAdapter} from './AwsEksAdapter'
function fakeEksService() {
return {
listClusters: async () => [],
describeCluster: async () => ({name: 'cluster-a', tags: {}}),
listNodegroups: async (clusterName: string) => [{
name: 'workers', clusterName, arn: 'arn:aws:eks:us-east-1:000000000000:nodegroup/cluster-a/workers/id',
status: 'ACTIVE', version: '1.30', releaseVersion: '1.30.0-20260801',
instanceTypes: ['t3.medium'], subnets: ['subnet-a'], nodeRole: 'arn:aws:iam::000000000000:role/workers',
scalingConfig: {minSize: 1, desiredSize: 2, maxSize: 3}, labels: {workload: 'api'}, tags: {team: 'platform'},
}],
createNodegroup: async (clusterName: string, input: unknown) => ({
name: (input as {name: string}).name, clusterName, instanceTypes: [], subnets: [], labels: {}, tags: {},
}),
deleteNodegroup: async () => ({name: 'workers', clusterName: 'cluster-a', instanceTypes: [], subnets: [], labels: {}, tags: {}}),
listFargateProfiles: async (clusterName: string) => [{
name: 'default', clusterName, arn: 'arn:aws:eks:us-east-1:000000000000:fargateprofile/cluster-a/default/id',
status: 'ACTIVE', podExecutionRoleArn: 'arn:aws:iam::000000000000:role/fargate', subnets: ['subnet-a'],
selectors: [{namespace: 'default', labels: {app: 'api'}}], tags: {team: 'platform'},
}],
createFargateProfile: async (clusterName: string, input: unknown) => ({
name: (input as {name: string}).name, clusterName, subnets: [], selectors: [], tags: {},
}),
deleteFargateProfile: async () => ({name: 'default', clusterName: 'cluster-a', subnets: [], selectors: [], tags: {}}),
}
}
describe('AwsEksAdapter', () => {
test('maps EKS nodegroups through the provider-neutral contract', async () => {
const adapter = new AwsEksAdapter(fakeEksService())
await expect(adapter.listKubernetesNodegroups('cluster-a')).resolves.toEqual([expect.objectContaining({
id: 'workers',
clusterId: 'cluster-a',
capacityType: null,
scalingConfig: {minSize: 1, desiredSize: 2, maxSize: 3},
})])
})
test('validates nodegroup input before calling the runtime', async () => {
const adapter = new AwsEksAdapter(fakeEksService())
await expect(adapter.createKubernetesNodegroup('cluster-a', {
name: 'workers', nodeRole: '', subnets: ['subnet-a'],
})).rejects.toThrow('Node role ARN is required')
})
test('maps EKS Fargate profiles through the provider-neutral contract', async () => {
const adapter = new AwsEksAdapter(fakeEksService())
await expect(adapter.listKubernetesFargateProfiles('cluster-a')).resolves.toEqual([expect.objectContaining({
id: 'default',
clusterId: 'cluster-a',
selectors: [{namespace: 'default', labels: {app: 'api'}}],
})])
})
test('validates that a Fargate profile has a selector namespace', async () => {
const adapter = new AwsEksAdapter(fakeEksService())
await expect(adapter.createKubernetesFargateProfile('cluster-a', {
name: 'default', podExecutionRoleArn: 'arn:aws:iam::000000000000:role/fargate', selectors: [],
})).rejects.toThrow('At least one selector namespace is required')
})
})
@@ -0,0 +1,183 @@
import {NotSupportedError} from '../cloud-spi/errors'
import {awsEksSchema} from '../cloud-spi/eksSchema'
import type {
CloudResource,
CloudServiceAdapter,
CreateKubernetesFargateProfileInput,
CreateKubernetesNodegroupInput,
CreateResourceInput,
KubernetesFargateProfile,
KubernetesNodegroup,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
import {ValidationError} from '../cloud-spi/errors'
import {eksService, type EksCluster, type EksFargateProfile, type EksNodegroup} from '../services/eks'
type EksServiceShape = Pick<
typeof eksService,
| 'listClusters'
| 'describeCluster'
| 'listNodegroups'
| 'createNodegroup'
| 'deleteNodegroup'
| 'listFargateProfiles'
| 'createFargateProfile'
| 'deleteFargateProfile'
>
export class AwsEksAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'k8s' as const
constructor(private readonly eks: EksServiceShape = eksService) {}
schema(): ServiceSchema {
return awsEksSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const clusters = await this.eks.listClusters()
return filterBySearch(clusters.map(toResource), query.search)
}
async get(id: string): Promise<CloudResource | null> {
try {
return toResource(await this.eks.describeCluster(id))
} catch (error) {
if (hasHttpStatus(error, 404)) return null
throw error
}
}
async create(_input: CreateResourceInput): Promise<CloudResource> {
throw new NotSupportedError('EKS cluster creation is not supported from the dynamic Cloud Explorer.')
}
async delete(_id: string): Promise<void> {
throw new NotSupportedError('EKS cluster deletion is not supported from the dynamic Cloud Explorer.')
}
async listKubernetesNodegroups(clusterId: string): Promise<KubernetesNodegroup[]> {
return (await this.eks.listNodegroups(clusterId)).map(toNodegroup)
}
async createKubernetesNodegroup(
clusterId: string,
input: CreateKubernetesNodegroupInput,
): Promise<KubernetesNodegroup> {
validateNodegroupInput(input)
return toNodegroup(await this.eks.createNodegroup(clusterId, input))
}
async deleteKubernetesNodegroup(clusterId: string, nodegroupId: string): Promise<void> {
await this.eks.deleteNodegroup(clusterId, nodegroupId)
}
async listKubernetesFargateProfiles(clusterId: string): Promise<KubernetesFargateProfile[]> {
return (await this.eks.listFargateProfiles(clusterId)).map(toFargateProfile)
}
async createKubernetesFargateProfile(
clusterId: string,
input: CreateKubernetesFargateProfileInput,
): Promise<KubernetesFargateProfile> {
validateFargateProfileInput(input)
return toFargateProfile(await this.eks.createFargateProfile(clusterId, input))
}
async deleteKubernetesFargateProfile(clusterId: string, profileId: string): Promise<void> {
await this.eks.deleteFargateProfile(clusterId, profileId)
}
}
function toResource(cluster: EksCluster): CloudResource {
return {
id: cluster.name,
name: cluster.name,
cloud: 'aws',
service: 'k8s',
type: 'cluster',
region: null,
createdAt: cluster.createdAt ?? null,
status: cluster.status ?? null,
version: cluster.version ?? null,
metadata: {
arn: cluster.arn,
endpoint: cluster.endpoint,
roleArn: cluster.roleArn,
platformVersion: cluster.platformVersion,
nodegroupCount: cluster.nodegroupCount ?? 0,
fargateProfileCount: cluster.fargateProfileCount ?? 0,
resourcesVpcConfig: cluster.resourcesVpcConfig,
tags: Object.entries(cluster.tags).map(([key, value]) => ({key, value})),
},
}
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
}
function hasHttpStatus(error: unknown, status: number): boolean {
if (typeof error !== 'object' || error === null) return false
const metadata = (error as {$metadata?: {httpStatusCode?: number}}).$metadata
return metadata?.httpStatusCode === status
}
function toNodegroup(nodegroup: EksNodegroup): KubernetesNodegroup {
return {
id: nodegroup.name,
name: nodegroup.name,
clusterId: nodegroup.clusterName,
arn: nodegroup.arn ?? null,
status: nodegroup.status ?? null,
version: nodegroup.version ?? null,
releaseVersion: nodegroup.releaseVersion ?? null,
createdAt: nodegroup.createdAt ?? null,
modifiedAt: nodegroup.modifiedAt ?? null,
capacityType: nodegroup.capacityType ?? null,
instanceTypes: nodegroup.instanceTypes,
subnets: nodegroup.subnets,
nodeRole: nodegroup.nodeRole ?? null,
scalingConfig: nodegroup.scalingConfig ?? null,
labels: nodegroup.labels,
tags: nodegroup.tags,
}
}
function toFargateProfile(profile: EksFargateProfile): KubernetesFargateProfile {
return {
id: profile.name,
name: profile.name,
clusterId: profile.clusterName,
arn: profile.arn ?? null,
status: profile.status ?? null,
createdAt: profile.createdAt ?? null,
podExecutionRoleArn: profile.podExecutionRoleArn ?? null,
subnets: profile.subnets,
selectors: profile.selectors.map((selector) => ({
namespace: selector.namespace ?? null,
labels: selector.labels,
})),
tags: profile.tags,
}
}
function validateNodegroupInput(input: CreateKubernetesNodegroupInput): void {
if (typeof input.name !== 'string' || !input.name.trim()) throw new ValidationError('Nodegroup name is required')
if (typeof input.nodeRole !== 'string' || !input.nodeRole.trim()) throw new ValidationError('Node role ARN is required')
if (!Array.isArray(input.subnets) || input.subnets.length === 0 || input.subnets.some((subnet) => typeof subnet !== 'string' || !subnet.trim())) {
throw new ValidationError('At least one subnet is required')
}
}
function validateFargateProfileInput(input: CreateKubernetesFargateProfileInput): void {
if (typeof input.name !== 'string' || !input.name.trim()) throw new ValidationError('Fargate profile name is required')
if (typeof input.podExecutionRoleArn !== 'string' || !input.podExecutionRoleArn.trim()) throw new ValidationError('Pod execution role ARN is required')
if (!Array.isArray(input.selectors) || input.selectors.length === 0 || input.selectors.some((selector) => typeof selector.namespace !== 'string' || !selector.namespace.trim())) {
throw new ValidationError('At least one selector namespace is required')
}
}
@@ -0,0 +1,81 @@
import {describe, expect, test} from 'bun:test'
import {
CreateEventBusCommand,
DeleteEventBusCommand,
DescribeEventBusCommand,
ListEventBusesCommand,
type EventBridgeClient,
} from '@aws-sdk/client-eventbridge'
import {AwsEventBridgeAdapter} from './AwsEventBridgeAdapter'
type SendHandler = (command: unknown) => Promise<unknown>
describe('AwsEventBridgeAdapter', () => {
test('identifies EventBridge through the separate events category', () => {
const adapter = createAdapter()
expect(adapter.schema()).toMatchObject({cloud: 'aws', service: 'events', displayName: 'EventBridge'})
expect(adapter.schema().actions).toEqual(['list', 'create', 'delete', 'inspect'])
expect(adapter.descriptorOverride()).toEqual({displayName: 'EventBridge'})
})
test('lists every EventBridge page and filters by name', async () => {
const tokens: Array<string | undefined> = []
const adapter = createAdapter(async (command) => {
expect(command).toBeInstanceOf(ListEventBusesCommand)
const token = (command as ListEventBusesCommand).input.NextToken
tokens.push(token)
return token
? {EventBuses: [{Name: 'billing'}]}
: {EventBuses: [{Name: 'orders', Arn: 'arn:aws:events:us-east-1:000000000000:event-bus/orders'}], NextToken: 'next'}
})
expect(tokens).toEqual([])
await expect(adapter.list({search: 'bill'})).resolves.toMatchObject([{name: 'billing', type: 'event-bus'}])
expect(tokens).toEqual([undefined, 'next'])
})
test('inspects an event bus and returns null when it does not exist', async () => {
const adapter = createAdapter(async (command) => {
if (command instanceof DescribeEventBusCommand) {
expect(command.input.Name).toBe('orders')
return {Name: 'orders', Arn: 'arn:aws:events:eu-west-2:000000000000:event-bus/orders', Description: 'Order events'}
}
return {}
})
await expect(adapter.get('event-bus:orders')).resolves.toMatchObject({
name: 'orders', region: 'eu-west-2', metadata: {description: 'Order events'},
})
const missing = createAdapter(async () => {
throw Object.assign(new Error('missing'), {name: 'ResourceNotFoundException'})
})
await expect(missing.get('event-bus:missing')).resolves.toBeNull()
})
test('creates and deletes an event bus with its normalized id', async () => {
const commands: unknown[] = []
const adapter = createAdapter(async (command) => {
commands.push(command)
if (command instanceof CreateEventBusCommand) {
return {EventBusArn: 'arn:aws:events:us-east-1:000000000000:event-bus/orders'}
}
return {}
})
await expect(adapter.create({values: {name: 'orders', description: 'Order events'}})).resolves.toMatchObject({
id: 'event-bus:orders', type: 'event-bus', metadata: {description: 'Order events'},
})
await adapter.delete('event-bus:orders')
expect(commands[0]).toBeInstanceOf(CreateEventBusCommand)
expect((commands[0] as CreateEventBusCommand).input).toEqual({Name: 'orders', Description: 'Order events'})
expect(commands[1]).toBeInstanceOf(DeleteEventBusCommand)
expect((commands[1] as DeleteEventBusCommand).input.Name).toBe('orders')
})
})
function createAdapter(handler: SendHandler = async () => ({EventBuses: []})): AwsEventBridgeAdapter {
return new AwsEventBridgeAdapter({send: handler} as unknown as EventBridgeClient)
}
@@ -0,0 +1,124 @@
import {
CreateEventBusCommand,
DeleteEventBusCommand,
DescribeEventBusCommand,
ListEventBusesCommand,
type EventBridgeClient,
type EventBus,
} from '@aws-sdk/client-eventbridge'
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
import {ValidationError} from '../cloud-spi/errors'
import type {
CloudResource,
CloudServiceAdapter,
CloudServiceDescriptorOverride,
CreateResourceInput,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
const EVENT_BUS_PREFIX = 'event-bus:'
/** AWS EventBridge event buses through the provider-neutral Events category. */
export class AwsEventBridgeAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'events' as const
constructor(private readonly eventbridge: EventBridgeClient) {}
schema(): ServiceSchema {
return awsEventBridgeSchema()
}
descriptorOverride(): CloudServiceDescriptorOverride {
return {displayName: 'EventBridge'}
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const eventBuses: EventBus[] = []
let nextToken: string | undefined
do {
const response = await this.eventbridge.send(new ListEventBusesCommand({NextToken: nextToken}))
eventBuses.push(...(response.EventBuses ?? []))
nextToken = response.NextToken
} while (nextToken)
return filterBySearch(eventBuses.map(eventBusResource), query.search)
}
async get(id: string): Promise<CloudResource | null> {
const name = eventBusName(id)
try {
return eventBusResource(await this.eventbridge.send(new DescribeEventBusCommand({Name: name})))
} catch (error) {
if (isNotFound(error)) return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const name = requiredValue(input.values.name, 'name')
const description = stringValue(input.values.description) || undefined
const response = await this.eventbridge.send(new CreateEventBusCommand({Name: name, Description: description}))
return eventBusResource({Name: name, Arn: response.EventBusArn, Description: description})
}
async delete(id: string): Promise<void> {
await this.eventbridge.send(new DeleteEventBusCommand({Name: eventBusName(id)}))
}
}
function eventBusResource(eventBus: EventBus): CloudResource {
const name = eventBus.Name ?? ''
return {
id: `${EVENT_BUS_PREFIX}${name}`,
name,
cloud: 'aws',
service: 'events',
type: 'event-bus',
region: regionFromArn(eventBus.Arn),
createdAt: eventBus.CreationTime?.toISOString() ?? null,
status: null,
metadata: {
provider: 'aws',
eventsService: 'eventbridge',
arn: eventBus.Arn,
description: eventBus.Description,
policy: eventBus.Policy,
lastModifiedAt: eventBus.LastModifiedTime?.toISOString() ?? null,
},
}
}
function eventBusName(id: string): string {
const name = id.startsWith(EVENT_BUS_PREFIX) ? id.slice(EVENT_BUS_PREFIX.length) : ''
if (!name) throw new ValidationError(`Unknown EventBridge event bus id: ${id}`)
return name
}
function requiredValue(value: unknown, field: string): string {
const result = stringValue(value)
if (!result) throw new ValidationError(`${field} is required`)
return result
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
}
function regionFromArn(arn?: string): string | null {
return arn?.split(':')[3] || null
}
function isNotFound(error: unknown): boolean {
if (!error || typeof error !== 'object') return false
const value = error as {name?: string; $metadata?: {httpStatusCode?: number}}
return value.name === 'ResourceNotFoundException' || value.$metadata?.httpStatusCode === 404
}
@@ -0,0 +1,160 @@
import {describe, expect, test} from 'bun:test'
import {
CreateUserCommand,
DeleteUserCommand,
GetUserCommand,
ListUsersCommand,
type IAMClient,
} from '@aws-sdk/client-iam'
import {AwsIamAdapter} from './AwsIamAdapter'
function fakeClient(handler: (command: unknown) => unknown): IAMClient {
return {
send: async (command: unknown) => handler(command),
} as unknown as IAMClient
}
const alice = {
UserName: 'alice',
UserId: 'AIDAALICE',
Arn: 'arn:aws:iam::000000000000:user/team/alice',
Path: '/team/',
CreateDate: new Date('2026-01-02T03:04:05.000Z'),
}
describe('AwsIamAdapter', () => {
test('lists every page and maps IAM users to normalized resources', async () => {
const adapter = new AwsIamAdapter(fakeClient((command) => {
if (!(command instanceof ListUsersCommand)) throw new Error('Unexpected command')
if (!command.input.Marker) return {Users: [alice], IsTruncated: true, Marker: 'next'}
return {Users: [{...alice, UserName: 'bob', UserId: 'AIDABOB'}], IsTruncated: false}
}))
const result = await adapter.list()
expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({
id: 'alice',
name: 'alice',
cloud: 'aws',
service: 'identity',
type: 'iam-user',
region: null,
createdAt: '2026-01-02T03:04:05.000Z',
})
expect(result[0].metadata).toMatchObject({
identityService: 'iam',
userId: 'AIDAALICE',
path: '/team/',
})
})
test('filters users by search term', async () => {
const adapter = new AwsIamAdapter(fakeClient(() => ({
Users: [alice, {...alice, UserName: 'bob'}],
IsTruncated: false,
})))
const result = await adapter.list({search: 'ALI'})
expect(result.map((resource) => resource.name)).toEqual(['alice'])
})
test('gets and maps one IAM user', async () => {
const adapter = new AwsIamAdapter(fakeClient((command) => {
expect(command).toBeInstanceOf(GetUserCommand)
return {User: alice}
}))
const result = await adapter.get('alice')
expect(result?.id).toBe('alice')
expect(result?.metadata.arn).toBe(alice.Arn)
})
test('returns null when IAM reports a missing user', async () => {
const adapter = new AwsIamAdapter(fakeClient(() => {
throw Object.assign(new Error('missing'), {name: 'NoSuchEntityException'})
}))
await expect(adapter.get('missing')).resolves.toBeNull()
})
test('creates an IAM user with an optional path', async () => {
const adapter = new AwsIamAdapter(fakeClient((command) => {
expect(command).toBeInstanceOf(CreateUserCommand)
expect((command as CreateUserCommand).input).toEqual({UserName: 'alice', Path: '/team/'})
return {User: alice}
}))
const result = await adapter.create({values: {userName: 'alice', path: '/team/'}})
expect(result.id).toBe('alice')
})
test('reports an empty create response as a runtime error', async () => {
const adapter = new AwsIamAdapter(fakeClient(() => ({})))
await expect(adapter.create({values: {userName: 'alice'}})).rejects.toMatchObject({
name: 'RuntimeError',
message: 'AWS IAM did not return the created user',
})
})
test('rejects invalid user names before calling IAM', async () => {
let called = false
const adapter = new AwsIamAdapter(fakeClient(() => {
called = true
return {}
}))
await expect(adapter.create({values: {userName: 'not valid'}})).rejects.toThrow('Use a valid IAM user name')
await expect(adapter.create({values: {userName: 'alice', path: 'team'}})).rejects.toThrow('Use a valid IAM path')
expect(called).toBeFalse()
})
test('deletes the requested IAM user', async () => {
const adapter = new AwsIamAdapter(fakeClient((command) => {
expect(command).toBeInstanceOf(DeleteUserCommand)
expect((command as DeleteUserCommand).input.UserName).toBe('alice')
return {}
}))
await adapter.delete('alice')
})
test('reports a delete of a missing IAM user as not found', async () => {
const adapter = new AwsIamAdapter(fakeClient(() => {
throw Object.assign(new Error('The user with name alice cannot be found.'), {name: 'NoSuchEntityException'})
}))
await expect(adapter.delete('alice')).rejects.toMatchObject({
name: 'NotFoundError',
message: 'IAM user alice not found',
})
})
test('reports a delete blocked by attached resources as a conflict', async () => {
const adapter = new AwsIamAdapter(fakeClient(() => {
throw Object.assign(new Error('Cannot delete entity, must delete access keys first.'), {
name: 'DeleteConflictException',
})
}))
await expect(adapter.delete('alice')).rejects.toMatchObject({
name: 'ConflictError',
message: expect.stringContaining('IAM user alice still has attached resources'),
})
})
test('returns the AWS IAM schema', () => {
const adapter = new AwsIamAdapter(fakeClient(() => ({})))
expect(adapter.schema()).toMatchObject({
cloud: 'aws',
service: 'identity',
displayName: 'AWS IAM users',
actions: ['list', 'create', 'delete', 'inspect'],
})
})
})
@@ -0,0 +1,133 @@
import {
CreateUserCommand,
DeleteUserCommand,
GetUserCommand,
ListUsersCommand,
type IAMClient,
type User,
} from '@aws-sdk/client-iam'
import {iam as defaultIam} from '../aws'
import {
awsIamSchema,
IAM_PATH_MAX_LENGTH,
IAM_PATH_MESSAGE,
IAM_PATH_PATTERN,
IAM_USER_NAME_MESSAGE,
IAM_USER_NAME_PATTERN,
} from '../cloud-spi/iamSchema'
import {ConflictError, NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
const MAX_LIST_PAGES = 50
export class AwsIamAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'identity' as const
constructor(private readonly iam: IAMClient = defaultIam) {}
schema(): ServiceSchema {
return awsIamSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const users: User[] = []
let marker: string | undefined
let pages = 0
do {
const res = await this.iam.send(new ListUsersCommand({Marker: marker}))
users.push(...(res.Users ?? []))
marker = res.IsTruncated ? res.Marker : undefined
pages += 1
} while (marker && pages < MAX_LIST_PAGES)
return filterBySearch(users.map(toResource), query.search)
}
async get(id: string): Promise<CloudResource | null> {
try {
const res = await this.iam.send(new GetUserCommand({UserName: id}))
return res.User ? toResource(res.User) : null
} catch (error) {
if (isNotFound(error)) return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const userName = stringValue(input.values.userName)
const path = stringValue(input.values.path)
if (!userName) throw new ValidationError('userName is required')
if (!new RegExp(IAM_USER_NAME_PATTERN).test(userName)) throw new ValidationError(IAM_USER_NAME_MESSAGE)
if (path && (path.length > IAM_PATH_MAX_LENGTH || !new RegExp(IAM_PATH_PATTERN).test(path))) {
throw new ValidationError(IAM_PATH_MESSAGE)
}
const res = await this.iam.send(new CreateUserCommand({
UserName: userName,
Path: path || undefined,
}))
if (!res.User) throw new RuntimeError('AWS IAM did not return the created user')
return toResource(res.User)
}
async delete(id: string): Promise<void> {
try {
await this.iam.send(new DeleteUserCommand({UserName: id}))
} catch (error) {
if (isNotFound(error)) throw new NotFoundError(`IAM user ${id} not found`, {cause: error})
if (isDeleteConflict(error)) {
throw new ConflictError(
`IAM user ${id} still has attached resources. Remove group memberships, access keys, `
+ 'certificates, MFA devices, and policies before deleting the user.',
{cause: error},
)
}
throw error
}
}
}
function toResource(user: User): CloudResource {
const userName = user.UserName ?? ''
return {
id: userName,
name: userName,
cloud: 'aws',
service: 'identity',
type: 'iam-user',
region: null,
createdAt: user.CreateDate?.toISOString() ?? null,
metadata: {
provider: 'aws',
identityService: 'iam',
userId: user.UserId,
arn: user.Arn,
path: user.Path,
permissionsBoundary: user.PermissionsBoundary,
tags: user.Tags,
},
}
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
}
function isNotFound(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false
const candidate = error as {name?: string; $metadata?: {httpStatusCode?: number}}
return candidate.name === 'NoSuchEntityException' || candidate.$metadata?.httpStatusCode === 404
}
function isDeleteConflict(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false
return (error as {name?: string}).name === 'DeleteConflictException'
}
@@ -0,0 +1,461 @@
import {describe, expect, test} from 'bun:test'
import {
CreateKeyCommand,
DecryptCommand,
DescribeKeyCommand,
EncryptCommand,
type KMSClient,
ListKeysCommand,
ListResourceTagsCommand,
ScheduleKeyDeletionCommand,
TagResourceCommand,
UntagResourceCommand,
} from '@aws-sdk/client-kms'
import {AwsKmsAdapter} from './AwsKmsAdapter'
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
/** Minimal KMSClient stub that records the commands it was sent. */
function stubKms(handler: (command: object) => SendResult | Promise<SendResult>) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
return handler(command)
},
} as unknown as KMSClient
return {client, sent}
}
const KEY_ID = '68543643-23b6-4037-a59b-4d43d5d308f1'
const KEY_ARN = `arn:aws:kms:us-east-1:000000000000:key/${KEY_ID}`
const keyMetadata = {
AWSAccountId: '000000000000',
KeyId: KEY_ID,
Arn: KEY_ARN,
CreationDate: new Date('2026-07-28T10:00:00.000Z'),
Enabled: true,
Description: 'payments signing key',
KeyUsage: 'ENCRYPT_DECRYPT',
KeyState: 'Enabled',
Origin: 'AWS_KMS',
KeyManager: 'CUSTOMER',
KeySpec: 'SYMMETRIC_DEFAULT',
EncryptionAlgorithms: ['SYMMETRIC_DEFAULT'],
}
/** Routes each command to the response the real runtime gives for it. */
function runtimeStub(overrides: Record<string, SendResult> = {}) {
return stubKms((command) => {
if (command instanceof ListKeysCommand) {
return overrides.list ?? {Keys: [{KeyId: KEY_ID, KeyArn: KEY_ARN}]}
}
if (command instanceof DescribeKeyCommand) {
return overrides.describe ?? {KeyMetadata: keyMetadata}
}
if (command instanceof ListResourceTagsCommand) {
return overrides.tags ?? {Tags: []}
}
return {}
})
}
describe('AwsKmsAdapter', () => {
test('identifies itself as the AWS KMS adapter', () => {
const adapter = new AwsKmsAdapter(runtimeStub().client)
expect(adapter.cloud).toBe('aws')
expect(adapter.service).toBe('kms')
expect(adapter.schema().displayName).toBe('AWS KMS')
})
test('lists keys by fanning out DescribeKey over the thin ListKeys result', async () => {
// ListKeys returns only id and ARN, so every displayed column depends on
// the per-key DescribeKey call.
const {client, sent} = runtimeStub()
const [resource] = await new AwsKmsAdapter(client).list()
expect(sent[0]).toBeInstanceOf(ListKeysCommand)
expect(sent[1]).toBeInstanceOf(DescribeKeyCommand)
expect(resource).toMatchObject({
id: KEY_ID,
name: KEY_ID,
cloud: 'aws',
service: 'kms',
type: 'key',
region: 'us-east-1',
status: 'Enabled',
createdAt: '2026-07-28T10:00:00.000Z',
})
expect(resource?.metadata).toMatchObject({
arn: KEY_ARN,
description: 'payments signing key',
keyUsage: 'ENCRYPT_DECRYPT',
keySpec: 'SYMMETRIC_DEFAULT',
keyManager: 'CUSTOMER',
origin: 'AWS_KMS',
enabled: true,
})
})
test('keeps keys that are pending deletion in the list', async () => {
// ScheduleKeyDeletion is a soft delete and real AWS keeps listing the key,
// so hiding it here would misreport the account.
const {client} = runtimeStub({
describe: {KeyMetadata: {...keyMetadata, KeyState: 'PendingDeletion', Enabled: false}},
})
const [resource] = await new AwsKmsAdapter(client).list()
expect(resource?.status).toBe('PendingDeletion')
})
test('surfaces key tags under metadata', async () => {
const {client} = runtimeStub({tags: {Tags: [{TagKey: 'env', TagValue: 'prod'}]}})
const [resource] = await new AwsKmsAdapter(client).list()
expect(resource?.metadata.tags).toEqual([{key: 'env', value: 'prod'}])
})
test('still lists keys when the tag lookup fails', async () => {
// list() builds every row through Promise.all, so an unisolated tag
// failure would reject the whole view and show nothing, when the key
// metadata was available all along.
const {client} = stubKms((command) => {
if (command instanceof ListKeysCommand) return {Keys: [{KeyId: KEY_ID, KeyArn: KEY_ARN}]}
if (command instanceof DescribeKeyCommand) return {KeyMetadata: keyMetadata}
throw Object.assign(new Error('Rate exceeded'), {
name: 'ThrottlingException',
$metadata: {httpStatusCode: 400},
})
})
const [resource] = await new AwsKmsAdapter(client).list()
expect(resource?.id).toBe(KEY_ID)
expect(resource?.metadata.tags).toEqual([])
// Degraded, not silent.
expect(resource?.metadata.tagsUnavailable).toBe(true)
})
test('does not claim tags are unavailable when a key simply has none', async () => {
const {client} = runtimeStub()
const [resource] = await new AwsKmsAdapter(client).list()
expect(resource?.metadata.tags).toEqual([])
expect(resource?.metadata.tagsUnavailable).toBeUndefined()
})
test('filters the list by key id or description', async () => {
// The name is an opaque uuid, so a search that ignored the description
// would be unusable.
const adapter = new AwsKmsAdapter(runtimeStub().client)
await expect(adapter.list({search: '68543643'})).resolves.toHaveLength(1)
await expect(adapter.list({search: 'payments'})).resolves.toHaveLength(1)
await expect(adapter.list({search: 'nope'})).resolves.toHaveLength(0)
})
test('returns null when a key does not exist', async () => {
const {client} = stubKms(() => {
throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}})
})
await expect(new AwsKmsAdapter(client).get('missing')).resolves.toBeNull()
})
/**
* Real KMS is one of the AWS services that does not use 404 for not-found:
* it answers `NotFoundException` with HTTP 400. The local runtime sends 404,
* so a status-only check passes locally and turns a missing key into a 502
* against real AWS.
*/
test('returns null for the real KMS not-found shape, which carries HTTP 400', async () => {
const {client} = stubKms(() => {
throw Object.assign(new Error('Key ARN does not exist'), {
name: 'NotFoundException',
$metadata: {httpStatusCode: 400},
})
})
await expect(new AwsKmsAdapter(client).get('missing')).resolves.toBeNull()
})
test('rethrows a non-404 failure from get', async () => {
const {client} = stubKms(() => {
throw Object.assign(new Error('AccessDenied'), {$metadata: {httpStatusCode: 403}})
})
await expect(new AwsKmsAdapter(client).get(KEY_ID)).rejects.toThrow('AccessDenied')
})
test('inspects a key through DescribeKey', async () => {
const {client, sent} = runtimeStub()
const resource = await new AwsKmsAdapter(client).get(KEY_ID)
expect(sent[0]).toBeInstanceOf(DescribeKeyCommand)
expect((sent[0] as DescribeKeyCommand).input.KeyId).toBe(KEY_ID)
expect(resource?.id).toBe(KEY_ID)
})
test('creates a key with the requested description and usage', async () => {
const {client, sent} = stubKms(() => ({KeyMetadata: keyMetadata}))
const resource = await new AwsKmsAdapter(client).create({
values: {description: 'payments signing key', keyUsage: 'ENCRYPT_DECRYPT'},
})
const command = sent[0] as CreateKeyCommand
expect(command).toBeInstanceOf(CreateKeyCommand)
expect(command.input.Description).toBe('payments signing key')
expect(command.input.KeyUsage).toBe('ENCRYPT_DECRYPT')
expect(resource.id).toBe(KEY_ID)
})
test('defaults key usage and spec when they are omitted', async () => {
const {client, sent} = stubKms(() => ({KeyMetadata: keyMetadata}))
await new AwsKmsAdapter(client).create({values: {}})
const command = sent[0] as CreateKeyCommand
expect(command.input.KeyUsage).toBe('ENCRYPT_DECRYPT')
expect(command.input.KeySpec).toBe('SYMMETRIC_DEFAULT')
})
describe('key usage and spec compatibility', () => {
// The local runtime accepts SIGN_VERIFY with SYMMETRIC_DEFAULT and
// ENCRYPT_DECRYPT with ECC_NIST_P256 and returns 200, but real KMS rejects
// both. Only these tests can hold the line, so they encode the provider's
// rules rather than the emulator's.
test('defaults the spec to one that is valid for the chosen usage', async () => {
const cases: Array<[string, string]> = [
['ENCRYPT_DECRYPT', 'SYMMETRIC_DEFAULT'],
['SIGN_VERIFY', 'RSA_2048'],
['GENERATE_VERIFY_MAC', 'HMAC_256'],
]
for (const [keyUsage, expectedSpec] of cases) {
const {client, sent} = stubKms(() => ({KeyMetadata: keyMetadata}))
await new AwsKmsAdapter(client).create({values: {keyUsage}})
expect(String((sent[0] as CreateKeyCommand).input.KeySpec)).toBe(expectedSpec)
}
})
test('rejects a spec that the chosen usage cannot use', async () => {
const cases: Array<[string, string]> = [
['SIGN_VERIFY', 'SYMMETRIC_DEFAULT'],
['ENCRYPT_DECRYPT', 'ECC_NIST_P256'],
['ENCRYPT_DECRYPT', 'HMAC_256'],
['GENERATE_VERIFY_MAC', 'RSA_2048'],
]
for (const [keyUsage, keySpec] of cases) {
// Returns valid metadata, so a create that reaches the runtime
// succeeds — the only way this rejects is the compatibility check.
const {client, sent} = stubKms(() => ({KeyMetadata: keyMetadata}))
const adapter = new AwsKmsAdapter(client)
await expect(adapter.create({values: {keyUsage, keySpec}})).rejects.toThrow(ValidationError)
expect(sent, `${keyUsage}/${keySpec} must be rejected before reaching KMS`).toHaveLength(0)
}
})
test('accepts every pair the provider considers valid', async () => {
const cases: Array<[string, string]> = [
['ENCRYPT_DECRYPT', 'SYMMETRIC_DEFAULT'],
['ENCRYPT_DECRYPT', 'RSA_2048'],
['ENCRYPT_DECRYPT', 'RSA_4096'],
['SIGN_VERIFY', 'RSA_2048'],
['SIGN_VERIFY', 'RSA_4096'],
['SIGN_VERIFY', 'ECC_NIST_P256'],
['GENERATE_VERIFY_MAC', 'HMAC_256'],
]
for (const [keyUsage, keySpec] of cases) {
const {client, sent} = stubKms(() => ({KeyMetadata: keyMetadata}))
await new AwsKmsAdapter(client).create({values: {keyUsage, keySpec}})
expect(String((sent[0] as CreateKeyCommand).input.KeySpec)).toBe(keySpec)
}
})
})
test('follows the tag pages so a heavily tagged key is not truncated', async () => {
let tagCall = 0
const {client} = stubKms((command) => {
if (command instanceof ListKeysCommand) return {Keys: [{KeyId: KEY_ID, KeyArn: KEY_ARN}]}
if (command instanceof DescribeKeyCommand) return {KeyMetadata: keyMetadata}
tagCall += 1
if (tagCall === 1) {
return {Tags: [{TagKey: 'a', TagValue: '1'}], Truncated: true, NextMarker: 'tag-page-2'}
}
return {Tags: [{TagKey: 'b', TagValue: '2'}], Truncated: false}
})
const [resource] = await new AwsKmsAdapter(client).list()
expect(resource?.metadata.tags).toEqual([
{key: 'a', value: '1'},
{key: 'b', value: '2'},
])
})
test('rejects a key usage the schema does not offer', async () => {
const adapter = new AwsKmsAdapter(stubKms(() => ({})).client)
await expect(adapter.create({values: {keyUsage: 'MINE_BITCOIN'}})).rejects.toThrow(ValidationError)
})
test('rejects a description longer than the KMS limit', async () => {
const adapter = new AwsKmsAdapter(stubKms(() => ({})).client)
await expect(adapter.create({values: {description: 'x'.repeat(8193)}})).rejects.toThrow(ValidationError)
})
test('encrypts symmetric plaintext after validating the selected key', async () => {
const ciphertext = new Uint8Array([9, 8, 7])
const {client, sent} = stubKms((command) => {
if (command instanceof DescribeKeyCommand) return {KeyMetadata: keyMetadata}
if (command instanceof EncryptCommand) {
return {CiphertextBlob: ciphertext, KeyId: KEY_ARN, EncryptionAlgorithm: 'SYMMETRIC_DEFAULT'}
}
return {}
})
const result = await new AwsKmsAdapter(client).encrypt(KEY_ID, {
plaintext: new TextEncoder().encode('hello'),
encryptionAlgorithm: 'SYMMETRIC_DEFAULT',
encryptionContext: {purpose: 'test'},
})
expect(sent[0]).toBeInstanceOf(DescribeKeyCommand)
const command = sent[1] as EncryptCommand
expect(command).toBeInstanceOf(EncryptCommand)
expect(command.input.KeyId).toBe(KEY_ID)
expect(command.input.EncryptionContext).toEqual({purpose: 'test'})
expect(new TextDecoder().decode(command.input.Plaintext)).toBe('hello')
expect(result).toEqual({
ciphertextBlob: ciphertext,
keyId: KEY_ARN,
encryptionAlgorithm: 'SYMMETRIC_DEFAULT',
})
})
test('rejects plaintext above the RSA OAEP limit before encrypting', async () => {
const {client, sent} = stubKms((command) => {
if (command instanceof DescribeKeyCommand) {
return {KeyMetadata: {...keyMetadata, KeySpec: 'RSA_2048'}}
}
return {}
})
await expect(new AwsKmsAdapter(client).encrypt(KEY_ID, {
plaintext: new Uint8Array(191),
encryptionAlgorithm: 'RSAES_OAEP_SHA_256',
})).rejects.toThrow('190 bytes or fewer')
expect(sent).toHaveLength(1)
})
test('rejects an encryption context for RSA keys', async () => {
const {client, sent} = stubKms((command) => {
if (command instanceof DescribeKeyCommand) {
return {KeyMetadata: {...keyMetadata, KeySpec: 'RSA_4096'}}
}
return {}
})
await expect(new AwsKmsAdapter(client).encrypt(KEY_ID, {
plaintext: new Uint8Array([1]),
encryptionAlgorithm: 'RSAES_OAEP_SHA_256',
encryptionContext: {secret: 'not-supported'},
})).rejects.toThrow('do not support encryptionContext')
expect(sent).toHaveLength(1)
})
test('rejects crypto operations for disabled keys', async () => {
const {client, sent} = stubKms((command) => {
if (command instanceof DescribeKeyCommand) {
return {KeyMetadata: {...keyMetadata, Enabled: false, KeyState: 'Disabled'}}
}
return {}
})
await expect(new AwsKmsAdapter(client).encrypt(KEY_ID, {
plaintext: new Uint8Array([1]),
encryptionAlgorithm: 'SYMMETRIC_DEFAULT',
})).rejects.toThrow('must be enabled')
expect(sent).toHaveLength(1)
})
test('decrypts ciphertext with the selected key and context', async () => {
const plaintext = new TextEncoder().encode('round trip')
const {client, sent} = stubKms((command) => {
if (command instanceof DescribeKeyCommand) return {KeyMetadata: keyMetadata}
if (command instanceof DecryptCommand) {
return {Plaintext: plaintext, KeyId: KEY_ARN, EncryptionAlgorithm: 'SYMMETRIC_DEFAULT'}
}
return {}
})
const result = await new AwsKmsAdapter(client).decrypt(KEY_ID, {
ciphertextBlob: new Uint8Array([3, 2, 1]),
encryptionAlgorithm: 'SYMMETRIC_DEFAULT',
encryptionContext: {purpose: 'test'},
})
const command = sent[1] as DecryptCommand
expect(command).toBeInstanceOf(DecryptCommand)
expect(command.input.CiphertextBlob).toEqual(new Uint8Array([3, 2, 1]))
expect(command.input.EncryptionContext).toEqual({purpose: 'test'})
expect(new TextDecoder().decode(result.plaintext)).toBe('round trip')
})
test('treats a missing provider ciphertext as a runtime contract failure', async () => {
const {client} = stubKms((command) => {
if (command instanceof DescribeKeyCommand) return {KeyMetadata: keyMetadata}
return {}
})
await expect(new AwsKmsAdapter(client).encrypt(KEY_ID, {
plaintext: new Uint8Array([1]),
encryptionAlgorithm: 'SYMMETRIC_DEFAULT',
})).rejects.toThrow(RuntimeError)
})
test('treats a missing provider plaintext as a runtime contract failure', async () => {
const {client} = stubKms((command) => {
if (command instanceof DescribeKeyCommand) return {KeyMetadata: keyMetadata}
return {}
})
await expect(new AwsKmsAdapter(client).decrypt(KEY_ID, {
ciphertextBlob: new Uint8Array([1]),
encryptionAlgorithm: 'SYMMETRIC_DEFAULT',
})).rejects.toThrow(RuntimeError)
})
test('deletes a key by scheduling deletion with the shortest window', async () => {
const {client, sent} = stubKms(() => ({KeyId: KEY_ARN, DeletionDate: new Date()}))
await new AwsKmsAdapter(client).delete(KEY_ID)
const command = sent[0] as ScheduleKeyDeletionCommand
expect(command).toBeInstanceOf(ScheduleKeyDeletionCommand)
expect(command.input.KeyId).toBe(KEY_ID)
expect(command.input.PendingWindowInDays).toBe(7)
})
test('adds and removes tags in one update', async () => {
const {client, sent} = stubKms(() => ({}))
await new AwsKmsAdapter(client).updateTags(KEY_ID, {env: 'prod', stale: null})
const tagged = sent.find((c) => c instanceof TagResourceCommand) as TagResourceCommand
const untagged = sent.find((c) => c instanceof UntagResourceCommand) as UntagResourceCommand
expect(tagged.input.Tags).toEqual([{TagKey: 'env', TagValue: 'prod'}])
expect(untagged.input.TagKeys).toEqual(['stale'])
})
test('skips the tag calls it has nothing to send', async () => {
const {client, sent} = stubKms(() => ({}))
await new AwsKmsAdapter(client).updateTags(KEY_ID, {env: 'prod'})
expect(sent.some((c) => c instanceof UntagResourceCommand)).toBe(false)
})
})
@@ -0,0 +1,419 @@
import {
CreateKeyCommand,
DecryptCommand,
DescribeKeyCommand,
EncryptCommand,
type KeyMetadata,
type KMSClient,
ListKeysCommand,
ListResourceTagsCommand,
ScheduleKeyDeletionCommand,
TagResourceCommand,
UntagResourceCommand,
} from '@aws-sdk/client-kms'
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import {
awsKmsSchema,
KMS_DESCRIPTION_MAX_LENGTH,
KMS_KEY_SPECS,
KMS_KEY_USAGES,
KMS_SPECS_BY_USAGE,
} from '../cloud-spi/kmsSchema'
import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
KmsDecryptInput,
KmsDecryptResult,
KmsEncryptInput,
KmsEncryptionAlgorithm,
KmsEncryptResult,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
type KeyTag = {key: string; value: string}
/** Tags plus whether the lookup itself failed, so the two stay distinguishable. */
type TagLookup = {tags: KeyTag[]; unavailable?: true}
/**
* The shortest deletion window KMS accepts. Deletion is irreversible once it
* completes, so the console never offers a longer wait than it has to — but it
* also cannot delete immediately, which is why `delete` schedules.
*/
const PENDING_DELETION_WINDOW_DAYS = 7
const SYMMETRIC_PLAINTEXT_MAX_BYTES = 4_096
const DECRYPT_CIPHERTEXT_MAX_BYTES = 6_144
const RSA_PLAINTEXT_MAX_BYTES = {
RSA_2048: {
RSAES_OAEP_SHA_1: 214,
RSAES_OAEP_SHA_256: 190,
},
RSA_4096: {
RSAES_OAEP_SHA_1: 470,
RSAES_OAEP_SHA_256: 446,
},
} as const
export class AwsKmsAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'kms' as const
constructor(private readonly kms: KMSClient) {}
schema(): ServiceSchema {
return awsKmsSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const ids = await this.listKeyIds()
const resources = await Promise.all(ids.map((id) => this.describe(id)))
return filterBySearch(resources.filter((resource): resource is CloudResource => resource !== null), query.search)
}
async get(id: string): Promise<CloudResource | null> {
return this.describe(id)
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const description = optionalString(input.values.description, 'description')
if (description && description.length > KMS_DESCRIPTION_MAX_LENGTH) {
throw new ValidationError(`description must be ${KMS_DESCRIPTION_MAX_LENGTH} characters or fewer`)
}
const keyUsage = oneOf(input.values.keyUsage, KMS_KEY_USAGES, 'keyUsage')
const keySpec = keySpecFor(keyUsage, input.values.keySpec)
const res = await this.kms.send(
new CreateKeyCommand({
...(description ? {Description: description} : {}),
KeyUsage: keyUsage,
KeySpec: keySpec,
}),
)
if (!res.KeyMetadata) throw new ValidationError('KMS did not return metadata for the created key')
// A brand new key has no tags, so this skips the ListResourceTags call.
return toResource(res.KeyMetadata, {tags: []})
}
/**
* KMS has no immediate delete: the key enters PendingDeletion and is destroyed
* when the window elapses. It keeps appearing in `list()` until then, which is
* the runtime's behaviour and not something to hide.
*/
async delete(id: string): Promise<void> {
await this.kms.send(
new ScheduleKeyDeletionCommand({KeyId: id, PendingWindowInDays: PENDING_DELETION_WINDOW_DAYS}),
)
}
async updateTags(id: string, tags: Record<string, string | null>): Promise<void> {
const added = Object.entries(tags).filter((entry): entry is [string, string] => entry[1] !== null)
const removed = Object.keys(tags).filter((key) => tags[key] === null)
if (added.length > 0) {
await this.kms.send(
new TagResourceCommand({
KeyId: id,
Tags: added.map(([key, value]) => ({TagKey: key, TagValue: value})),
}),
)
}
if (removed.length > 0) {
await this.kms.send(new UntagResourceCommand({KeyId: id, TagKeys: removed}))
}
}
async encrypt(id: string, input: KmsEncryptInput): Promise<KmsEncryptResult> {
const metadata = await this.describeForCrypto(id)
validateCryptoKey(metadata, input.encryptionAlgorithm, input.encryptionContext)
validatePlaintextSize(metadata, input.plaintext, input.encryptionAlgorithm)
const res = await this.kms.send(
new EncryptCommand({
KeyId: id,
Plaintext: input.plaintext,
EncryptionAlgorithm: input.encryptionAlgorithm,
...(input.encryptionContext ? {EncryptionContext: input.encryptionContext} : {}),
}),
)
if (!res.CiphertextBlob) throw new RuntimeError('KMS did not return ciphertext')
return {
ciphertextBlob: res.CiphertextBlob,
keyId: res.KeyId ?? id,
encryptionAlgorithm: responseAlgorithm(res.EncryptionAlgorithm, input.encryptionAlgorithm),
}
}
async decrypt(id: string, input: KmsDecryptInput): Promise<KmsDecryptResult> {
const metadata = await this.describeForCrypto(id)
validateCryptoKey(metadata, input.encryptionAlgorithm, input.encryptionContext)
if (input.ciphertextBlob.byteLength === 0) throw new ValidationError('ciphertextBlobBase64 must not be empty')
if (input.ciphertextBlob.byteLength > DECRYPT_CIPHERTEXT_MAX_BYTES) {
throw new ValidationError(`ciphertextBlobBase64 must decode to ${DECRYPT_CIPHERTEXT_MAX_BYTES} bytes or fewer`)
}
const res = await this.kms.send(
new DecryptCommand({
KeyId: id,
CiphertextBlob: input.ciphertextBlob,
EncryptionAlgorithm: input.encryptionAlgorithm,
...(input.encryptionContext ? {EncryptionContext: input.encryptionContext} : {}),
}),
)
if (!res.Plaintext) throw new RuntimeError('KMS did not return plaintext')
return {
plaintext: res.Plaintext,
keyId: res.KeyId ?? id,
encryptionAlgorithm: responseAlgorithm(res.EncryptionAlgorithm, input.encryptionAlgorithm),
}
}
/** ListKeys pages at 100 keys, so an account past that would silently truncate. */
private async listKeyIds(): Promise<string[]> {
const ids: string[] = []
let marker: string | undefined
do {
const res = await this.kms.send(new ListKeysCommand(marker ? {Marker: marker} : {}))
for (const key of res.Keys ?? []) {
if (key.KeyId) ids.push(key.KeyId)
}
marker = res.Truncated ? res.NextMarker : undefined
} while (marker)
return ids
}
/** Crypto actions need key metadata but not the extra tag lookup used by get(). */
private async describeForCrypto(id: string): Promise<KeyMetadata> {
const res = await this.kms.send(new DescribeKeyCommand({KeyId: id}))
if (!res.KeyMetadata) throw new RuntimeError('KMS did not return key metadata')
return res.KeyMetadata
}
/**
* ListKeys returns only an id and an ARN, so every column the table shows
* comes from this per-key DescribeKey call.
*/
private async describe(id: string): Promise<CloudResource | null> {
try {
const res = await this.kms.send(new DescribeKeyCommand({KeyId: id}))
if (!res.KeyMetadata) return null
return toResource(res.KeyMetadata, await this.getTags(id))
} catch (error) {
if (isNotFound(error)) return null
throw error
}
}
/**
* Paged like ListKeys, so a heavily tagged key would otherwise be truncated.
*
* Tags are enrichment, so a failure degrades the row rather than the request:
* `list` builds every row through `Promise.all`, so an unisolated throttle
* would reject the whole view. Isolating it here also stops a tag 404 — a key
* deleted mid-list — from reaching `describe`'s catch and silently dropping
* the key. The failure is reported rather than swallowed, so an untagged key
* and an unreadable one stay distinguishable.
*/
private async getTags(id: string): Promise<TagLookup> {
const tags: KeyTag[] = []
let marker: string | undefined
try {
do {
const res = await this.kms.send(
new ListResourceTagsCommand({KeyId: id, ...(marker ? {Marker: marker} : {})}),
)
for (const tag of res.Tags ?? []) {
tags.push({key: tag.TagKey ?? '', value: tag.TagValue ?? ''})
}
marker = res.Truncated ? res.NextMarker : undefined
} while (marker)
} catch {
// Keep whatever pages already arrived rather than discarding them.
return {tags, unavailable: true}
}
return {tags}
}
}
function validateCryptoKey(
metadata: KeyMetadata,
algorithm: KmsEncryptionAlgorithm,
encryptionContext: Record<string, string> | undefined,
): void {
if (metadata.KeyUsage !== 'ENCRYPT_DECRYPT') {
throw new ValidationError('Selected key does not support encryption and decryption')
}
if (metadata.Enabled !== true || metadata.KeyState !== 'Enabled') {
throw new ValidationError('Selected key must be enabled')
}
const keySpec = metadata.KeySpec ?? metadata.CustomerMasterKeySpec
if (keySpec === 'SYMMETRIC_DEFAULT') {
if (algorithm !== 'SYMMETRIC_DEFAULT') {
throw new ValidationError('Symmetric keys require SYMMETRIC_DEFAULT')
}
return
}
if (keySpec !== 'RSA_2048' && keySpec !== 'RSA_4096') {
throw new ValidationError('Selected key spec does not support encryption and decryption')
}
if (algorithm !== 'RSAES_OAEP_SHA_1' && algorithm !== 'RSAES_OAEP_SHA_256') {
throw new ValidationError('RSA keys require an RSAES_OAEP encryption algorithm')
}
if (encryptionContext && Object.keys(encryptionContext).length > 0) {
throw new ValidationError('RSA keys do not support encryptionContext')
}
}
function validatePlaintextSize(
metadata: KeyMetadata,
plaintext: Uint8Array,
algorithm: KmsEncryptionAlgorithm,
): void {
if (plaintext.byteLength === 0) throw new ValidationError('plaintextBase64 must not be empty')
const keySpec = metadata.KeySpec ?? metadata.CustomerMasterKeySpec
if (keySpec === 'SYMMETRIC_DEFAULT') {
if (plaintext.byteLength > SYMMETRIC_PLAINTEXT_MAX_BYTES) {
throw new ValidationError(`plaintextBase64 must decode to ${SYMMETRIC_PLAINTEXT_MAX_BYTES} bytes or fewer`)
}
return
}
if (keySpec !== 'RSA_2048' && keySpec !== 'RSA_4096') return
if (algorithm !== 'RSAES_OAEP_SHA_1' && algorithm !== 'RSAES_OAEP_SHA_256') return
const limit = RSA_PLAINTEXT_MAX_BYTES[keySpec][algorithm]
if (plaintext.byteLength > limit) {
throw new ValidationError(`plaintextBase64 must decode to ${limit} bytes or fewer for ${keySpec}/${algorithm}`)
}
}
function responseAlgorithm(
value: string | undefined,
fallback: KmsEncryptionAlgorithm,
): KmsEncryptionAlgorithm {
if (value === undefined) return fallback
if (value === 'SYMMETRIC_DEFAULT' || value === 'RSAES_OAEP_SHA_1' || value === 'RSAES_OAEP_SHA_256') {
return value
}
throw new RuntimeError('KMS returned an unsupported encryption algorithm')
}
function toResource(metadata: KeyMetadata, tagLookup: TagLookup): CloudResource {
const id = metadata.KeyId ?? ''
return {
id,
// KMS keys have no name field; the uuid is the identity.
name: id,
cloud: 'aws',
service: 'kms',
type: 'key',
region: regionFromArn(metadata.Arn),
createdAt: metadata.CreationDate ? metadata.CreationDate.toISOString() : null,
status: metadata.KeyState ?? null,
metadata: {
arn: metadata.Arn,
accountId: metadata.AWSAccountId,
description: metadata.Description,
keyUsage: metadata.KeyUsage,
keySpec: metadata.KeySpec,
keyManager: metadata.KeyManager,
origin: metadata.Origin,
enabled: metadata.Enabled,
deletionDate: metadata.DeletionDate ? metadata.DeletionDate.toISOString() : null,
encryptionAlgorithms: metadata.EncryptionAlgorithms,
signingAlgorithms: metadata.SigningAlgorithms,
multiRegion: metadata.MultiRegion,
tags: tagLookup.tags,
...(tagLookup.unavailable ? {tagsUnavailable: true} : {}),
},
}
}
/** `arn:aws:kms:us-east-1:000000000000:key/<uuid>` — the region is the 4th field. */
function regionFromArn(arn: string | undefined): string | null {
return arn?.split(':')[3] || null
}
/** Matches on the id and the description, because the id alone is an opaque uuid. */
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter((resource) => {
const description = String(resource.metadata.description ?? '').toLowerCase()
return resource.name.toLowerCase().includes(normalized) || description.includes(normalized)
})
}
function optionalString(value: unknown, field: string): string | undefined {
if (value === undefined || value === null || value === '') return undefined
if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`)
return value
}
/**
* Resolve the key spec for a usage, defaulting to the first spec that usage
* supports rather than to a fixed symmetric default.
*
* A fixed default would pair SIGN_VERIFY with SYMMETRIC_DEFAULT, which real KMS
* rejects — and the local runtime accepts, so the failure would only appear
* against a real provider.
*/
function keySpecFor(keyUsage: (typeof KMS_KEY_USAGES)[number], raw: unknown): (typeof KMS_KEY_SPECS)[number] {
const allowed = KMS_SPECS_BY_USAGE[keyUsage]
const requested = oneOf(raw, KMS_KEY_SPECS, 'keySpec', allowed[0])
if (!(allowed as readonly string[]).includes(requested)) {
throw new ValidationError(
`keySpec ${requested} is not valid for keyUsage ${keyUsage}; use one of ${allowed.join(', ')}`,
)
}
return requested
}
/**
* Keeps create honest about the schema: a value the form never offers would be
* rejected by KMS anyway, and failing here gives a typed error instead of a 400.
*/
function oneOf<T extends readonly string[]>(
value: unknown,
allowed: T,
field: string,
fallback: T[number] = allowed[0] as T[number],
): T[number] {
const raw = optionalString(value, field)
if (raw === undefined) return fallback
if (!allowed.includes(raw)) {
throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
}
return raw as T[number]
}
/**
* KMS is one of the AWS services that does not use 404 for not-found: real KMS
* answers `NotFoundException` with HTTP **400**. The local runtime happens to
* send 404, so a status-only check passes every test here and would start
* throwing 502s the moment the runtime tightened to the real contract — the same
* shape of bug as matching a wire code the SDK never produces. Match the name
* first and keep the status as a fallback for the emulator.
*/
function isNotFound(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false
if ((error as {name?: string}).name === 'NotFoundException') return true
const metadata = (error as {$metadata?: {httpStatusCode?: number}}).$metadata
return metadata?.httpStatusCode === 404
}
@@ -0,0 +1,223 @@
import {describe, expect, test} from 'bun:test'
import {
CreateLoadBalancerCommand,
DeleteLoadBalancerCommand,
DescribeLoadBalancersCommand,
type ElasticLoadBalancingV2Client,
} from '@aws-sdk/client-elastic-load-balancing-v2'
import {AwsLoadBalancingAdapter} from './AwsLoadBalancingAdapter'
import {ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
function stubElb(handler: (command: object) => SendResult | Promise<SendResult>) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
return handler(command)
},
} as unknown as ElasticLoadBalancingV2Client
return {client, sent}
}
const ARN = 'arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/77aa388f69144785'
const balancer = {
LoadBalancerArn: ARN,
LoadBalancerName: 'web',
DNSName: 'web-77aa388f69144785.elb.localhost.floci.io',
// The runtime nests this: <State><Code>provisioning</Code></State>
State: {Code: 'provisioning'},
Type: 'application',
Scheme: 'internet-facing',
VpcId: 'vpc-default',
CreatedTime: new Date('2026-07-28T17:04:35.028Z'),
AvailabilityZones: [
{ZoneName: 'us-east-1b', SubnetId: 'subnet-default-b'},
{ZoneName: 'us-east-1c', SubnetId: 'subnet-default-c'},
],
SecurityGroups: ['sg-1'],
}
function runtimeStub() {
return stubElb((command) => {
if (command instanceof CreateLoadBalancerCommand) return {LoadBalancers: [balancer]}
if (command instanceof DescribeLoadBalancersCommand) return {LoadBalancers: [balancer]}
return {}
})
}
const validValues = {name: 'web', subnets: 'subnet-default-b, subnet-default-c'}
describe('AwsLoadBalancingAdapter', () => {
test('identifies itself as the AWS load balancing adapter', () => {
const adapter = new AwsLoadBalancingAdapter(runtimeStub().client)
expect(adapter.cloud).toBe('aws')
expect(adapter.service).toBe('loadbalancing')
expect(adapter.schema().displayName).toBe('AWS Elastic Load Balancing')
})
test('lists load balancers and reads the nested state code', async () => {
const {client, sent} = runtimeStub()
const [resource] = await new AwsLoadBalancingAdapter(client).list()
expect(sent[0]).toBeInstanceOf(DescribeLoadBalancersCommand)
expect(resource).toMatchObject({
// The ARN is the id: Describe and Delete both take one.
id: ARN,
name: 'web',
cloud: 'aws',
service: 'loadbalancing',
type: 'load-balancer',
region: 'us-east-1',
// State is nested, not a flat field — reading State directly yields
// an object and the column would render blank.
status: 'provisioning',
createdAt: '2026-07-28T17:04:35.028Z',
})
expect(resource?.metadata).toMatchObject({
dnsName: 'web-77aa388f69144785.elb.localhost.floci.io',
lbType: 'application',
scheme: 'internet-facing',
vpcId: 'vpc-default',
subnets: ['subnet-default-b', 'subnet-default-c'],
availabilityZones: ['us-east-1b', 'us-east-1c'],
})
})
test('follows the pagination marker', async () => {
let call = 0
const {client, sent} = stubElb((command) => {
if (!(command instanceof DescribeLoadBalancersCommand)) return {}
call += 1
if (call === 1) return {LoadBalancers: [balancer], NextMarker: 'page-2'}
return {LoadBalancers: [{...balancer, LoadBalancerName: 'api', LoadBalancerArn: `${ARN}-2`}]}
})
const resources = await new AwsLoadBalancingAdapter(client).list()
expect(resources.map((r) => r.name)).toEqual(['web', 'api'])
expect((sent[1] as DescribeLoadBalancersCommand).input.Marker).toBe('page-2')
})
test('treats an empty NextMarker as the end of the list', async () => {
// The runtime returns <NextMarker></NextMarker>, which the SDK surfaces as
// an empty string; looping on truthiness alone would spin forever.
let call = 0
const {client} = stubElb((command) => {
if (!(command instanceof DescribeLoadBalancersCommand)) return {}
call += 1
return {LoadBalancers: [balancer], NextMarker: ''}
})
await expect(new AwsLoadBalancingAdapter(client).list()).resolves.toHaveLength(1)
expect(call).toBe(1)
})
test('filters by name and ARN', async () => {
const adapter = new AwsLoadBalancingAdapter(runtimeStub().client)
await expect(adapter.list({search: 'we'})).resolves.toHaveLength(1)
await expect(adapter.list({search: 'loadbalancer/app'})).resolves.toHaveLength(1)
await expect(adapter.list({search: 'nope'})).resolves.toHaveLength(0)
})
test('inspects a load balancer by ARN', async () => {
const {client, sent} = runtimeStub()
const resource = await new AwsLoadBalancingAdapter(client).get(ARN)
expect((sent[0] as DescribeLoadBalancersCommand).input.LoadBalancerArns).toEqual([ARN])
expect(resource?.id).toBe(ARN)
})
test('returns null on LoadBalancerNotFound even though the runtime answers 400', async () => {
// ELB reports a missing load balancer as LoadBalancerNotFound with HTTP 400,
// not 404, so matching on the status would rethrow instead of returning null.
const {client} = stubElb(() => {
throw Object.assign(new Error('One or more load balancers not found.'), {
name: 'LoadBalancerNotFound',
$metadata: {httpStatusCode: 400},
})
})
await expect(new AwsLoadBalancingAdapter(client).get(ARN)).resolves.toBeNull()
})
test('also returns null for the SDK-modelled exception name', async () => {
// The wire <Code> is LoadBalancerNotFound but the SDK calls the error
// LoadBalancerNotFoundException. Matching only the wire code passed every
// stubbed test here and still returned 400 against the live runtime.
const {client} = stubElb(() => {
throw Object.assign(new Error('One or more load balancers not found.'), {
name: 'LoadBalancerNotFoundException',
$metadata: {httpStatusCode: 400},
})
})
await expect(new AwsLoadBalancingAdapter(client).get(ARN)).resolves.toBeNull()
})
test('returns null when the runtime answers with no load balancers', async () => {
const {client} = stubElb(() => ({LoadBalancers: []}))
await expect(new AwsLoadBalancingAdapter(client).get(ARN)).resolves.toBeNull()
})
test('rethrows a failure that is not a missing load balancer', async () => {
const {client} = stubElb(() => {
throw Object.assign(new Error('AccessDenied'), {
name: 'AccessDeniedException',
$metadata: {httpStatusCode: 403},
})
})
await expect(new AwsLoadBalancingAdapter(client).get(ARN)).rejects.toThrow('AccessDenied')
})
test('creates a load balancer, splitting the comma separated subnets', async () => {
const {client, sent} = runtimeStub()
const resource = await new AwsLoadBalancingAdapter(client).create({values: validValues})
const command = sent[0] as CreateLoadBalancerCommand
expect(command).toBeInstanceOf(CreateLoadBalancerCommand)
expect(command.input.Name).toBe('web')
expect(command.input.Subnets).toEqual(['subnet-default-b', 'subnet-default-c'])
expect(command.input.Type).toBe('application')
expect(command.input.Scheme).toBe('internet-facing')
expect(resource.id).toBe(ARN)
})
test('requires at least two subnet ids', async () => {
// Only the count is enforced. The AZ requirement is ELB's, and checking it
// would mean calling EC2 from this adapter, so the message must not claim it.
const adapter = new AwsLoadBalancingAdapter(runtimeStub().client)
await expect(adapter.create({values: {...validValues, subnets: 'subnet-only'}})).rejects.toThrow(
new ValidationError('subnets must list at least two subnet ids'),
)
})
test('requires the fields the schema marks required', async () => {
const adapter = new AwsLoadBalancingAdapter(runtimeStub().client)
await expect(adapter.create({values: {}})).rejects.toThrow(new ValidationError('name is required'))
await expect(adapter.create({values: {name: 'web'}})).rejects.toThrow(
new ValidationError('subnets is required'),
)
})
test('rejects values the runtime would refuse', async () => {
const adapter = new AwsLoadBalancingAdapter(runtimeStub().client)
for (const name of ['-web', 'web-', 'has spaces', 'a'.repeat(33)]) {
await expect(adapter.create({values: {...validValues, name}}), name).rejects.toThrow(ValidationError)
}
await expect(adapter.create({values: {...validValues, type: 'gateway'}})).rejects.toThrow(ValidationError)
await expect(adapter.create({values: {...validValues, scheme: 'sideways'}})).rejects.toThrow(ValidationError)
})
test('deletes a load balancer by ARN', async () => {
const {client, sent} = runtimeStub()
await new AwsLoadBalancingAdapter(client).delete(ARN)
expect((sent[0] as DeleteLoadBalancerCommand).input.LoadBalancerArn).toBe(ARN)
})
})
@@ -0,0 +1,185 @@
import {
CreateLoadBalancerCommand,
DeleteLoadBalancerCommand,
DescribeLoadBalancersCommand,
type ElasticLoadBalancingV2Client,
type LoadBalancer,
} from '@aws-sdk/client-elastic-load-balancing-v2'
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import {ELB_NAME_PATTERN, ELB_SCHEMES, ELB_TYPES, awsLoadBalancingSchema} from '../cloud-spi/loadBalancingSchema'
import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
/**
* Talks to Elastic Load Balancing — verified against Floci core 1.5.33.
*
* Notes from probing the runtime:
* - **Only ELBv2 is implemented.** A `Version=2012-06-01` request comes back in the
* `2015-12-01` namespace, so there is no separate classic ELB to model.
* - `State` is **nested** (`<State><Code>provisioning</Code></State>`). Reading
* `State` directly yields an object and the column renders blank.
* - A missing load balancer is `LoadBalancerNotFound` with HTTP **400**, not 404,
* so `get` matches on the error name. `awsErrors.ts` now maps that name (and
* `TargetGroupNotFound`) so the route reports 404 rather than a validation error.
* - The runtime returns `<NextMarker></NextMarker>` at the end of the list, which
* the SDK surfaces as an empty string — paging must stop on falsy, not on
* undefined, or it spins forever.
* - A new load balancer reports `provisioning` and then reaches `active`; the
* state is surfaced as-is rather than waited on.
*
* Target groups are deliberately excluded: they are a second resource kind in this
* category and would need the `kind` facet from `ResourceQuery.filters` (#162).
* Shipping load balancers alone keeps this PR independent of that one.
*/
export class AwsLoadBalancingAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'loadbalancing' as const
constructor(private readonly elb: ElasticLoadBalancingV2Client) {}
schema(): ServiceSchema {
return awsLoadBalancingSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const balancers: LoadBalancer[] = []
let marker: string | undefined
do {
const res = await this.elb.send(new DescribeLoadBalancersCommand(marker ? {Marker: marker} : {}))
balancers.push(...(res.LoadBalancers ?? []))
marker = res.NextMarker || undefined
} while (marker)
return filterBySearch(balancers.map(toResource), query.search)
}
async get(id: string): Promise<CloudResource | null> {
try {
const res = await this.elb.send(new DescribeLoadBalancersCommand({LoadBalancerArns: [id]}))
const balancer = res.LoadBalancers?.[0]
return balancer ? toResource(balancer) : null
} catch (error) {
if (isMissing(error)) return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const name = requiredString(input.values.name, 'name')
if (!new RegExp(ELB_NAME_PATTERN).test(name)) {
throw new ValidationError(
'name must be up to 32 characters of letters, digits and hyphens, not starting or ending with a hyphen',
)
}
// Only the count is enforced. Whether the subnets sit in different
// availability zones is ELB's rule, and checking it here would mean calling
// EC2 DescribeSubnets from the load balancing adapter — real coupling for a
// validation nicety. So the message claims only what this check does; the
// schema field carries the AZ requirement as guidance.
const subnets = splitIds(requiredString(input.values.subnets, 'subnets'))
if (subnets.length < 2) {
throw new ValidationError('subnets must list at least two subnet ids')
}
const type = optionalOneOf(input.values.type, ELB_TYPES, 'type') ?? 'application'
const scheme = optionalOneOf(input.values.scheme, ELB_SCHEMES, 'scheme') ?? 'internet-facing'
const res = await this.elb.send(
new CreateLoadBalancerCommand({Name: name, Subnets: subnets, Type: type, Scheme: scheme}),
)
const created = res.LoadBalancers?.[0]
if (!created) throw new RuntimeError(`ELB did not return the created load balancer for ${name}`)
return toResource(created)
}
async delete(id: string): Promise<void> {
await this.elb.send(new DeleteLoadBalancerCommand({LoadBalancerArn: id}))
}
}
function toResource(balancer: LoadBalancer): CloudResource {
const arn = balancer.LoadBalancerArn ?? ''
const zones = balancer.AvailabilityZones ?? []
return {
id: arn,
name: balancer.LoadBalancerName ?? '',
cloud: 'aws',
service: 'loadbalancing',
type: 'load-balancer',
region: regionFromArn(arn),
createdAt: balancer.CreatedTime ? balancer.CreatedTime.toISOString() : null,
status: balancer.State?.Code ?? null,
metadata: {
arn,
dnsName: balancer.DNSName,
/** `type` is taken by CloudResource, so the ELB type lives here. */
lbType: balancer.Type,
scheme: balancer.Scheme,
vpcId: balancer.VpcId,
stateReason: balancer.State?.Reason,
subnets: zones.map((zone) => zone.SubnetId).filter((id): id is string => Boolean(id)),
availabilityZones: zones.map((zone) => zone.ZoneName).filter((z): z is string => Boolean(z)),
securityGroups: balancer.SecurityGroups,
ipAddressType: balancer.IpAddressType,
canonicalHostedZoneId: balancer.CanonicalHostedZoneId,
},
}
}
/** `arn:aws:elasticloadbalancing:us-east-1:...` — region is field 4. */
function regionFromArn(arn: string): string | null {
return arn.split(':')[3] || null
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter(
(resource) =>
resource.name.toLowerCase().includes(normalized) || resource.id.toLowerCase().includes(normalized),
)
}
/** Accepts the comma separated form the schema asks for, ignoring blanks. */
function splitIds(raw: string): string[] {
return raw
.split(',')
.map((part) => part.trim())
.filter(Boolean)
}
/**
* The wire `<Code>` is `LoadBalancerNotFound`, but the SDK names the modelled error
* `LoadBalancerNotFoundException`. Matching only the wire code passed every stubbed
* test and still returned 400 against the live runtime, so both are accepted.
*/
function isMissing(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false
const name = (error as {name?: string}).name
return name === 'LoadBalancerNotFound' || name === 'LoadBalancerNotFoundException'
}
function requiredString(value: unknown, field: string): string {
if (value === undefined || value === null || value === '') throw new ValidationError(`${field} is required`)
if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`)
const trimmed = value.trim()
if (!trimmed) throw new ValidationError(`${field} is required`)
return trimmed
}
function optionalOneOf<T extends readonly string[]>(value: unknown, allowed: T, field: string): T[number] | undefined {
if (value === undefined || value === null || value === '') return undefined
if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`)
const raw = value.trim()
if (!allowed.includes(raw)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
return raw as T[number]
}
@@ -0,0 +1,363 @@
import {describe, expect, test} from 'bun:test'
import {AwsLogsAdapter} from './AwsLogsAdapter'
/**
* Stubs the SDK client's `send`, dispatching on the command's constructor name.
* Matching the real SDK's *name* rather than the wire shape is deliberate — see
* .claude/memory/aws-sdk-error-names-differ-from-wire-codes.md.
*/
function stubClient(handlers: Record<string, (input: Record<string, unknown>) => unknown>) {
const calls: Array<{command: string; input: Record<string, unknown>}> = []
const client = {
async send(command: {constructor: {name: string}; input: Record<string, unknown>}) {
const name = command.constructor.name
calls.push({command: name, input: command.input})
const handler = handlers[name]
if (!handler) throw new Error(`unexpected command ${name}`)
return handler(command.input)
},
}
return {client: client as never, calls}
}
describe('AwsLogsAdapter resources', () => {
test('normalizes log groups as cloud resources', async () => {
const {client} = stubClient({
DescribeLogGroupsCommand: () => ({
logGroups: [{
logGroupName: '/floci/probe',
creationTime: 1785260691820,
arn: 'arn:aws:logs:us-east-1:000000000000:log-group:/floci/probe',
storedBytes: 0,
metricFilterCount: 0,
}],
}),
})
const adapter = new AwsLogsAdapter(client)
await expect(adapter.list()).resolves.toMatchObject([{
id: '/floci/probe',
name: '/floci/probe',
cloud: 'aws',
service: 'logs',
type: 'log-group',
createdAt: new Date(1785260691820).toISOString(),
}])
})
// The runtime filters natively by prefix, so `search` must not be a
// client-side contains — that would silently disagree with the field label.
test('search is sent as logGroupNamePrefix', async () => {
const {client, calls} = stubClient({DescribeLogGroupsCommand: () => ({logGroups: []})})
const adapter = new AwsLogsAdapter(client)
await adapter.list({search: '/floci'})
expect(calls[0].input).toMatchObject({logGroupNamePrefix: '/floci'})
})
test('get returns null for a group the runtime does not have', async () => {
const {client} = stubClient({DescribeLogGroupsCommand: () => ({logGroups: []})})
const adapter = new AwsLogsAdapter(client)
await expect(adapter.get('/nope')).resolves.toBeNull()
})
// DescribeLogGroups is a prefix match, so a longer name starting with the
// requested one would otherwise be returned as if it were an exact hit.
test('get matches the name exactly, not by prefix', async () => {
const {client} = stubClient({
DescribeLogGroupsCommand: () => ({logGroups: [{logGroupName: '/floci/probe-two', creationTime: 1}]}),
})
const adapter = new AwsLogsAdapter(client)
await expect(adapter.get('/floci/probe')).resolves.toBeNull()
})
test('create and delete issue the right commands', async () => {
const {client, calls} = stubClient({
CreateLogGroupCommand: () => ({}),
DeleteLogGroupCommand: () => ({}),
DescribeLogGroupsCommand: () => ({logGroups: [{logGroupName: '/floci/new', creationTime: 1}]}),
})
const adapter = new AwsLogsAdapter(client)
await expect(adapter.create({values: {name: '/floci/new'}})).resolves.toMatchObject({id: '/floci/new'})
await adapter.delete('/floci/new')
expect(calls.map((call) => call.command)).toEqual([
'CreateLogGroupCommand',
'DescribeLogGroupsCommand',
'DeleteLogGroupCommand',
])
})
/**
* `creationTime` is the field the SDK models and real AWS returns. The
* local runtime sends `createdTime`, which the SDK deserializer discards —
* so createdAt is legitimately null against the emulator, and a stub that
* feeds `createdTime` is testing a shape the SDK can never produce. That
* mistake shipped here once already; this pins the real contract.
*/
test('reads the creation timestamp the SDK actually models', async () => {
const {client} = stubClient({
DescribeLogGroupsCommand: () => ({logGroups: [{logGroupName: '/a', creationTime: 1785260691820}]}),
})
await expect(new AwsLogsAdapter(client).list()).resolves.toMatchObject([
{createdAt: new Date(1785260691820).toISOString()},
])
})
test('a group with no modelled timestamp reports null rather than a bogus date', async () => {
const {client} = stubClient({
DescribeLogGroupsCommand: () => ({logGroups: [{logGroupName: '/a'}]}),
})
await expect(new AwsLogsAdapter(client).list()).resolves.toMatchObject([{createdAt: null}])
})
test('create rejects a blank name before calling the runtime', async () => {
const {client, calls} = stubClient({})
const adapter = new AwsLogsAdapter(client)
await expect(adapter.create({values: {name: ' '}})).rejects.toThrow()
expect(calls).toEqual([])
})
})
describe('AwsLogsAdapter queryLogs', () => {
test('starts a query and maps result fields into row objects', async () => {
const {client, calls} = stubClient({
StartQueryCommand: () => ({queryId: 'q-1'}),
GetQueryResultsCommand: () => ({
status: 'Complete',
results: [
[{field: '@timestamp', value: '2026-01-01 00:00:00.000'}, {field: '@message', value: 'hello'}],
],
}),
})
const adapter = new AwsLogsAdapter(client)
const result = await adapter.queryLogs('/floci/probe', {
queryString: 'fields @timestamp, @message',
startTime: 1000,
endTime: 2000,
limit: 20,
})
expect(result).toEqual({
queryId: 'q-1',
status: 'Complete',
rows: [{'@timestamp': '2026-01-01 00:00:00.000', '@message': 'hello'}],
})
expect(calls[0]).toMatchObject({
command: 'StartQueryCommand',
input: {logGroupName: '/floci/probe', startTime: 1000, endTime: 2000, queryString: 'fields @timestamp, @message', limit: 20},
})
expect(calls[1].command).toBe('GetQueryResultsCommand')
})
// FLOCI_SERVICES_CLOUDWATCHLOGS_QUERY_COMPLETION_DELAY_MS defaults to 0, so a
// real completion normally takes one GetQueryResults call — but a positive
// delay (or real AWS) makes the first call(s) come back Running, so the
// adapter must keep polling rather than returning a stale in-progress result.
test('polls until the query leaves Running/Scheduled', async () => {
let getCalls = 0
const {client, calls} = stubClient({
StartQueryCommand: () => ({queryId: 'q-2'}),
GetQueryResultsCommand: () => {
getCalls += 1
if (getCalls < 3) return {status: getCalls === 1 ? 'Scheduled' : 'Running', results: []}
return {status: 'Complete', results: [[{field: '@message', value: 'done'}]]}
},
})
const adapter = new AwsLogsAdapter(client)
const result = await adapter.queryLogs('/floci/probe', {queryString: 'fields @message', startTime: 0, endTime: 1})
expect(result).toEqual({queryId: 'q-2', status: 'Complete', rows: [{'@message': 'done'}]})
expect(calls.filter((call) => call.command === 'GetQueryResultsCommand')).toHaveLength(3)
})
// The P1 this test pins: if the poll deadline is hit while the query is
// still Running/Scheduled, the caller must still get the queryId back —
// dropping it here would make that still-running query unrecoverable, even
// though Floci (or real AWS) keeps executing it server-side.
test('still returns the query id when the poll deadline is hit before completion', async () => {
const {client} = stubClient({
StartQueryCommand: () => ({queryId: 'q-3'}),
GetQueryResultsCommand: () => ({status: 'Running', results: []}),
})
const adapter = new AwsLogsAdapter(client, {timeoutMs: 30, intervalMs: 10})
const result = await adapter.queryLogs('/floci/probe', {queryString: 'fields @message', startTime: 0, endTime: 1})
expect(result.queryId).toBe('q-3')
expect(result.status).toBe('Running')
})
test('rejects a blank query string before calling the runtime', async () => {
const {client, calls} = stubClient({})
const adapter = new AwsLogsAdapter(client)
await expect(adapter.queryLogs('/floci/probe', {queryString: ' ', startTime: 0, endTime: 1}))
.rejects.toThrow()
expect(calls).toEqual([])
})
test('raises when the runtime does not hand back a query id', async () => {
const {client} = stubClient({StartQueryCommand: () => ({})})
const adapter = new AwsLogsAdapter(client)
await expect(adapter.queryLogs('/floci/probe', {queryString: 'fields @message', startTime: 0, endTime: 1}))
.rejects.toThrow()
})
})
describe('AwsLogsAdapter documents', () => {
test('lists log streams as collections, newest activity first', async () => {
const {client, calls} = stubClient({
DescribeLogStreamsCommand: () => ({
logStreams: [{
logStreamName: 'stream-a',
arn: 'arn:aws:logs:us-east-1:000000000000:log-group:/floci/probe:log-stream:stream-a',
creationTime: 1785260702176,
storedBytes: 70,
firstEventTimestamp: 1785260000000,
lastEventTimestamp: 1785260001000,
}],
nextToken: 'page-2',
}),
})
const adapter = new AwsLogsAdapter(client)
const page = await adapter.documents.listCollections('/floci/probe')
expect(page).toMatchObject({
items: [{id: 'stream-a', name: 'stream-a', parentId: '/floci/probe'}],
nextCursor: 'page-2',
})
expect(calls[0].input).toMatchObject({
logGroupName: '/floci/probe',
orderBy: 'LastEventTime',
descending: true,
})
})
/**
* Real AWS caps DescribeLogStreams Limit at 50 and rejects anything higher
* with a ValidationException. The SPI defaults to 100 and permits 1000, and
* the local runtime does not enforce the cap — so an unclamped value passes
* every local test and 400s in production. Encode the provider's rule.
*/
test('clamps the stream page size to the AWS maximum of 50', async () => {
const {client, calls} = stubClient({DescribeLogStreamsCommand: () => ({logStreams: []})})
const adapter = new AwsLogsAdapter(client)
await adapter.documents.listCollections('/floci/probe')
await adapter.documents.listCollections('/floci/probe', {limit: 1000})
await adapter.documents.listCollections('/floci/probe', {limit: 25})
expect(calls.map((call) => call.input.limit)).toEqual([50, 50, 25])
})
test('reads log events as items, oldest first', async () => {
const {client, calls} = stubClient({
GetLogEventsCommand: () => ({
events: [
{timestamp: 1785260000000, message: 'hello one', ingestionTime: 1785260702192},
{timestamp: 1785260001000, message: 'hello two', ingestionTime: 1785260702192},
],
nextForwardToken: 'f/2',
nextBackwardToken: 'b/0',
}),
})
const adapter = new AwsLogsAdapter(client)
const page = await adapter.documents.listItems('/floci/probe', 'stream-a')
// Real AWS gives events no id, so it is derived from timestamp and
// position. Two events sharing a millisecond still get distinct ids.
expect(page.items).toMatchObject([
{
id: '1785260000000-0',
collectionId: 'stream-a',
timestamp: new Date(1785260000000).toISOString(),
body: {message: 'hello one'},
},
{id: '1785260001000-1', collectionId: 'stream-a'},
])
expect(page.nextCursor).toBe('f/2')
// Without startFromHead the runtime returns the newest page, so paging
// forward from a fresh cursor would skip the history.
expect(calls[0].input).toMatchObject({startFromHead: true})
})
/**
* The termination rule. GetLogEvents echoes the supplied token back when
* there is nothing further, so a cursor loop that trusts a non-null token
* never ends. Verified against the runtime on 2026-07-28.
*/
test('stops paging when the runtime echoes the cursor back', async () => {
const {client} = stubClient({
GetLogEventsCommand: () => ({events: [], nextForwardToken: 'f/2', nextBackwardToken: 'b/2'}),
})
const adapter = new AwsLogsAdapter(client)
const page = await adapter.documents.listItems('/floci/probe', 'stream-a', {cursor: 'f/2'})
expect(page.items).toEqual([])
expect(page.nextCursor).toBeNull()
})
test('an empty first page terminates too', async () => {
const {client} = stubClient({
GetLogEventsCommand: () => ({events: [], nextForwardToken: 'f/0', nextBackwardToken: 'b/0'}),
})
const adapter = new AwsLogsAdapter(client)
await expect(adapter.documents.listItems('/floci/probe', 'stream-a')).resolves.toMatchObject({nextCursor: null})
})
test('creates and deletes a log stream', async () => {
const {client, calls} = stubClient({
CreateLogStreamCommand: () => ({}),
DeleteLogStreamCommand: () => ({}),
DescribeLogStreamsCommand: () => ({
logStreams: [{logStreamName: 'stream-b', creationTime: 1785260702176}],
}),
})
const adapter = new AwsLogsAdapter(client)
await expect(adapter.documents.createCollection!('/floci/probe', {values: {name: 'stream-b'}}))
.resolves.toMatchObject({id: 'stream-b', parentId: '/floci/probe'})
await adapter.documents.deleteCollection!('/floci/probe', 'stream-b')
expect(calls.map((call) => call.command)).toEqual([
'CreateLogStreamCommand',
'DescribeLogStreamsCommand',
'DeleteLogStreamCommand',
])
})
// Provider behaviour, verified: a missing group raises, a missing stream
// returns an empty page. The contract mirrors that rather than smoothing it.
test('a missing stream yields an empty page rather than an error', async () => {
const {client} = stubClient({
GetLogEventsCommand: () => ({events: [], nextForwardToken: 'f/0', nextBackwardToken: 'b/0'}),
})
const adapter = new AwsLogsAdapter(client)
await expect(adapter.documents.listItems('/floci/probe', 'nope')).resolves.toEqual({items: [], nextCursor: null})
})
test('the store advertises no item writes', () => {
const {client} = stubClient({})
const adapter = new AwsLogsAdapter(client)
expect(adapter.documents.putItem).toBeUndefined()
expect(adapter.documents.deleteItem).toBeUndefined()
expect(adapter.documents.queryItems).toBeUndefined()
})
})
@@ -0,0 +1,304 @@
import {
type CloudWatchLogsClient,
CreateLogGroupCommand,
CreateLogStreamCommand,
DeleteLogGroupCommand,
DeleteLogStreamCommand,
DescribeLogGroupsCommand,
DescribeLogStreamsCommand,
GetLogEventsCommand,
GetQueryResultsCommand,
StartQueryCommand,
} from '@aws-sdk/client-cloudwatch-logs'
import type {
ChildCollection,
ChildItem,
CollectionPage,
DocumentStoreAdapter,
PageQuery,
} from '../cloud-spi/childCollections'
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
import {awsLogsSchema} from '../cloud-spi/logsSchema'
import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
LogsInsightsQueryInput,
LogsInsightsQueryResult,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
// Real StartQuery is async and queries can run up to an hour server-side. Floci
// completes instantly by default (FLOCI_SERVICES_CLOUDWATCHLOGS_QUERY_COMPLETION_DELAY_MS=0),
// but this poll loop is bounded rather than unconditional so a future non-zero delay,
// or a real backing AWS account, cannot hang the request indefinitely.
const QUERY_POLL_INTERVAL_MS = 150
const QUERY_POLL_TIMEOUT_MS = 10_000
const ACTIVE_QUERY_STATUSES = new Set(['Running', 'Scheduled'])
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function toRow(fields: Array<{field?: string; value?: string}>): Record<string, string> {
const row: Record<string, string> = {}
for (const {field, value} of fields) {
if (field) row[field] = value ?? ''
}
return row
}
/**
* `creationTime` is the field real AWS returns and the only one the SDK models.
*
* The local runtime sends `createdTime` instead, and the SDK deserializer drops
* wire fields it does not model — so that value is unrecoverable here and
* `createdAt` is null for every log group and stream against the emulator.
* Reading the emulator's spelling instead would be dead code in production and
* would only ever pass against hand-built stubs, which is how this shipped
* wrong the first time. See
* .claude/memory/aws-sdk-error-names-differ-from-wire-codes.md — same failure
* mode, different field.
*/
interface LogGroupShape {
logGroupName?: string
creationTime?: number
arn?: string
storedBytes?: number
metricFilterCount?: number
retentionInDays?: number
}
function createdAtOf(record: {creationTime?: number}): string | null {
return record.creationTime ? new Date(record.creationTime).toISOString() : null
}
export class AwsLogsAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'logs' as const
readonly documents: DocumentStoreAdapter
constructor(
private readonly client: CloudWatchLogsClient,
/** Overridable so tests can shrink the wait instead of taking QUERY_POLL_TIMEOUT_MS for real. */
private readonly queryPoll: {timeoutMs?: number; intervalMs?: number} = {},
) {
this.documents = new LogGroupDocumentStore(client)
}
schema(): ServiceSchema {
return awsLogsSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const prefix = query.search?.trim()
const response = await this.client.send(
new DescribeLogGroupsCommand(prefix ? {logGroupNamePrefix: prefix} : {}),
)
return (response.logGroups ?? []).map(toResource)
}
async get(id: string): Promise<CloudResource | null> {
const group = await this.describeExact(id)
return group ? toResource(group) : null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const name = String(input.values.name ?? '').trim()
if (!name) throw new ValidationError('A log group name is required.')
await this.client.send(new CreateLogGroupCommand({logGroupName: name}))
const group = await this.describeExact(name)
if (!group) throw new NotFoundError(`Log group ${name} was created but could not be read back.`)
return toResource(group)
}
async delete(id: string): Promise<void> {
await this.client.send(new DeleteLogGroupCommand({logGroupName: id}))
}
async queryLogs(logGroupName: string, input: LogsInsightsQueryInput): Promise<LogsInsightsQueryResult> {
const queryString = input.queryString.trim()
if (!queryString) throw new ValidationError('A query string is required.')
const {queryId} = await this.client.send(
new StartQueryCommand({
logGroupName,
startTime: input.startTime,
endTime: input.endTime,
queryString,
limit: input.limit,
}),
)
if (!queryId) throw new NotFoundError(`Floci did not return a query id for ${logGroupName}.`)
const deadline = Date.now() + (this.queryPoll.timeoutMs ?? QUERY_POLL_TIMEOUT_MS)
const pollInterval = this.queryPoll.intervalMs ?? QUERY_POLL_INTERVAL_MS
let response = await this.client.send(new GetQueryResultsCommand({queryId}))
while (ACTIVE_QUERY_STATUSES.has(response.status ?? '') && Date.now() < deadline) {
await sleep(pollInterval)
response = await this.client.send(new GetQueryResultsCommand({queryId}))
}
return {
queryId,
status: response.status ?? 'Unknown',
rows: (response.results ?? []).map(toRow),
}
}
/**
* DescribeLogGroups filters by prefix, so `/floci/probe` also returns
* `/floci/probe-two`. Filtering to an exact match keeps `get` honest.
*/
private async describeExact(name: string): Promise<LogGroupShape | null> {
const response = await this.client.send(new DescribeLogGroupsCommand({logGroupNamePrefix: name}))
return (response.logGroups ?? []).find((group) => group.logGroupName === name) ?? null
}
}
/** AWS rejects a DescribeLogStreams Limit above this. GetLogEvents allows 10,000. */
const MAX_DESCRIBE_LOG_STREAMS_LIMIT = 50
interface LogStreamShape {
logStreamName?: string
arn?: string
creationTime?: number
storedBytes?: number
firstEventTimestamp?: number
lastEventTimestamp?: number
}
/**
* Streams are the collection level, events the leaf. There is deliberately no
* putItem/deleteItem/queryItems: AWS has no DeleteLogEvent, and PutLogEvents is
* not something a resource browser should expose.
*/
class LogGroupDocumentStore implements DocumentStoreAdapter {
constructor(private readonly client: CloudWatchLogsClient) {}
async listCollections(resourceId: string, page: PageQuery = {}): Promise<CollectionPage<ChildCollection>> {
const response = await this.client.send(
new DescribeLogStreamsCommand({
logGroupName: resourceId,
orderBy: 'LastEventTime',
descending: true,
// Real AWS caps this at 50 and rejects anything higher with a
// ValidationException; the SPI defaults to 100 and permits 1000.
// The local runtime does not enforce the cap, so an unclamped
// value passes here and 400s in production.
//
// Clamping is safe where rejecting would be wrong: `limit` is a
// maximum, not a quota, and the response still carries
// `nextCursor`, so a short page is honest rather than a silent
// truncation. That is the opposite of `clampLimit`, which
// rejects — there the caller has no cursor to tell them more
// exists.
limit: Math.min(page.limit ?? MAX_DESCRIBE_LOG_STREAMS_LIMIT, MAX_DESCRIBE_LOG_STREAMS_LIMIT),
nextToken: page.cursor,
}),
)
return {
items: (response.logStreams ?? []).map((stream) => toCollection(stream, resourceId)),
nextCursor: response.nextToken ?? null,
}
}
async createCollection(resourceId: string, input: CreateResourceInput): Promise<ChildCollection> {
const name = String(input.values.name ?? '').trim()
if (!name) throw new ValidationError('A log stream name is required.')
await this.client.send(new CreateLogStreamCommand({logGroupName: resourceId, logStreamName: name}))
const response = await this.client.send(
new DescribeLogStreamsCommand({logGroupName: resourceId, logStreamNamePrefix: name}),
)
const stream = (response.logStreams ?? []).find((candidate) => candidate.logStreamName === name)
if (!stream) throw new NotFoundError(`Log stream ${name} was created but could not be read back.`)
return toCollection(stream, resourceId)
}
async deleteCollection(resourceId: string, collectionId: string): Promise<void> {
await this.client.send(new DeleteLogStreamCommand({logGroupName: resourceId, logStreamName: collectionId}))
}
async listItems(resourceId: string, collectionId: string, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
const response = await this.client.send(
new GetLogEventsCommand({
logGroupName: resourceId,
logStreamName: collectionId,
// Without this the runtime returns the newest page, so paging
// forward from a fresh cursor would skip the history.
startFromHead: true,
limit: page.limit,
nextToken: page.cursor,
}),
)
const events = response.events ?? []
const forward = response.nextForwardToken ?? null
return {
items: events.map((event, index) => ({
// Real AWS gives log events no identifier — the SDK models only
// timestamp/message/ingestionTime, and the emulator's `eventId`
// is discarded by the deserializer. Position within the page is
// the only thing available, so it is not stable across calls and
// must not be used as a durable key.
id: `${event.timestamp ?? 0}-${index}`,
collectionId,
timestamp: event.timestamp ? new Date(event.timestamp).toISOString() : null,
body: {message: event.message ?? ''},
metadata: {
ingestionTime: event.ingestionTime ? new Date(event.ingestionTime).toISOString() : null,
},
})),
// GetLogEvents echoes the supplied token back at the end of the
// stream instead of returning null, so a naive loop never
// terminates. Verified against the runtime on 2026-07-28.
nextCursor: events.length === 0 || forward === page.cursor ? null : forward,
}
}
}
function toCollection(stream: LogStreamShape, parentId: string): ChildCollection {
const name = stream.logStreamName ?? ''
return {
id: name,
name,
parentId,
createdAt: createdAtOf(stream),
metadata: {
arn: stream.arn,
storedBytes: stream.storedBytes ?? 0,
firstEventTimestamp: stream.firstEventTimestamp
? new Date(stream.firstEventTimestamp).toISOString()
: null,
lastEventTimestamp: stream.lastEventTimestamp
? new Date(stream.lastEventTimestamp).toISOString()
: null,
},
}
}
function toResource(group: LogGroupShape): CloudResource {
const name = group.logGroupName ?? ''
return {
id: name,
name,
cloud: 'aws',
service: 'logs',
type: 'log-group',
region: null,
createdAt: createdAtOf(group),
metadata: {
arn: group.arn,
storedBytes: group.storedBytes ?? 0,
metricFilterCount: group.metricFilterCount ?? 0,
retentionInDays: group.retentionInDays ?? null,
},
}
}
@@ -0,0 +1,78 @@
import {NotSupportedError} from '../cloud-spi/errors'
import {awsNetworkingSchema} from '../cloud-spi/networkingSchema'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
import {ec2Service, type Ec2Tag, type Ec2Vpc} from '../services/ec2'
type Ec2NetworkingServiceShape = {
listVpcs(): Promise<Ec2Vpc[]>
}
/**
* Networking adapter for AWS.
*
* Exposes VPCs as the top-level resource in the generic Cloud Explorer table.
* All VPC, subnet, security-group, key-pair, IGW, route-table, and EIP management
* is handled by the dedicated NetworkingPanel component which calls the EC2 API
* routes directly — this adapter only drives the resource table and sidebar entry.
*/
export class AwsNetworkingAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'networking' as const
constructor(private readonly service_: Ec2NetworkingServiceShape = ec2Service) {}
schema(): ServiceSchema {
return awsNetworkingSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const vpcs = await this.service_.listVpcs()
return filterBySearch(vpcs.map(vpcToResource), query.search)
}
async get(id: string): Promise<CloudResource | null> {
// listVpcs is the only read the EC2 service exposes here, and a local
// emulator's VPC count is small enough that filtering it is cheaper than
// adding a describe path through the legacy service layer.
const vpcs = await this.service_.listVpcs()
const match = vpcs.find((vpc) => vpc.vpcId === id)
return match ? vpcToResource(match) : null
}
async create(_input: CreateResourceInput): Promise<CloudResource> {
throw new NotSupportedError('Use the Networking panel to create VPCs and networking resources.')
}
async delete(_id: string): Promise<void> {
throw new NotSupportedError('Use the Networking panel to delete networking resources.')
}
}
function vpcToResource(vpc: Ec2Vpc): CloudResource {
return {
id: vpc.vpcId,
name: nameTag(vpc.tags) || vpc.vpcId,
cloud: 'aws',
service: 'networking',
type: 'vpc',
region: null,
createdAt: null,
status: vpc.state ?? null,
version: vpc.cidrBlock,
metadata: {
cidrBlock: vpc.cidrBlock,
isDefault: vpc.isDefault,
tags: vpc.tags,
},
}
}
function nameTag(tags: Ec2Tag[]): string {
return tags.find((t) => t.key === 'Name')?.value ?? ''
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter((r) => r.name.toLowerCase().includes(normalized))
}
@@ -0,0 +1,300 @@
import {describe, expect, test} from 'bun:test'
import {
AddTagsToResourceCommand,
DeleteParameterCommand,
DescribeParametersCommand,
ListTagsForResourceCommand,
PutParameterCommand,
RemoveTagsFromResourceCommand,
type SSMClient,
} from '@aws-sdk/client-ssm'
import {AwsParameterStoreAdapter} from './AwsParameterStoreAdapter'
import {ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
/** Minimal SSMClient stub that records the commands it was sent. */
function stubSsm(handler: (command: object) => SendResult | Promise<SendResult>) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
return handler(command)
},
} as unknown as SSMClient
return {client, sent}
}
const plain = {
Name: '/floci/app/region',
Type: 'String',
Version: 1,
LastModifiedDate: new Date('2026-07-28T10:00:00.000Z'),
Description: 'deployment region',
DataType: 'text',
Tier: 'Standard',
}
const secure = {
Name: '/floci/app/token',
Type: 'SecureString',
Version: 3,
LastModifiedDate: new Date('2026-07-28T11:00:00.000Z'),
DataType: 'text',
}
function runtimeStub(overrides: {describe?: SendResult; tags?: SendResult} = {}) {
return stubSsm((command) => {
if (command instanceof DescribeParametersCommand) {
return overrides.describe ?? {Parameters: [plain, secure]}
}
if (command instanceof ListTagsForResourceCommand) {
return overrides.tags ?? {TagList: []}
}
if (command instanceof PutParameterCommand) return {Version: 1}
return {}
})
}
describe('AwsParameterStoreAdapter', () => {
test('identifies itself as the AWS Parameter Store adapter', () => {
const adapter = new AwsParameterStoreAdapter(runtimeStub().client)
expect(adapter.cloud).toBe('aws')
expect(adapter.service).toBe('parameters')
expect(adapter.schema().displayName).toBe('SSM Parameter Store')
})
test('lists parameters with their metadata', async () => {
const {client, sent} = runtimeStub()
const resources = await new AwsParameterStoreAdapter(client).list()
expect(sent[0]).toBeInstanceOf(DescribeParametersCommand)
expect(resources).toHaveLength(2)
expect(resources[0]).toMatchObject({
id: '/floci/app/region',
name: '/floci/app/region',
cloud: 'aws',
service: 'parameters',
type: 'parameter',
createdAt: '2026-07-28T10:00:00.000Z',
})
expect(resources[0]?.metadata).toMatchObject({
parameterType: 'String',
version: 1,
description: 'deployment region',
dataType: 'text',
tier: 'Standard',
})
})
test('never exposes a parameter value, not even for a plain String', async () => {
// metadata reaches the inspector, the query cache and telemetry, so a
// value must not travel with the resource. This is the same rule both
// secrets adapters pin, and it matters more here: the local runtime
// returns SecureString plaintext even without WithDecryption, so relying
// on the runtime to withhold it would leak.
const {client, sent} = runtimeStub()
const adapter = new AwsParameterStoreAdapter(client)
const resources = await adapter.list()
const inspected = await adapter.get('/floci/app/token')
for (const resource of [...resources, inspected]) {
expect(JSON.stringify(resource)).not.toContain('Value')
expect(resource?.metadata.value).toBeUndefined()
}
// The value is never even requested.
expect(sent.every((command) => command.constructor.name !== 'GetParameterCommand')).toBe(true)
})
test('marks that the value is withheld so the UI can explain the empty field', async () => {
const {client} = runtimeStub()
const [resource] = await new AwsParameterStoreAdapter(client).list()
expect(resource?.metadata.valueWithheld).toBe(true)
})
test('follows NextToken until the runtime stops paging', async () => {
// The local runtime ignores MaxResults and returns everything in one
// response, so only a stub can prove the loop works. Real SSM pages at 10.
let call = 0
const {client, sent} = stubSsm((command) => {
if (command instanceof ListTagsForResourceCommand) return {TagList: []}
call += 1
if (call === 1) return {Parameters: [plain], NextToken: 'page-2'}
if (call === 2) return {Parameters: [secure], NextToken: 'page-3'}
return {Parameters: [{...plain, Name: '/floci/app/third'}]}
})
const resources = await new AwsParameterStoreAdapter(client).list()
expect(resources.map((r) => r.id)).toEqual(['/floci/app/region', '/floci/app/token', '/floci/app/third'])
const describes = sent.filter((c) => c instanceof DescribeParametersCommand) as DescribeParametersCommand[]
expect(describes).toHaveLength(3)
expect(describes[1]?.input.NextToken).toBe('page-2')
expect(describes[2]?.input.NextToken).toBe('page-3')
})
test('filters the list by name or description', async () => {
const adapter = new AwsParameterStoreAdapter(runtimeStub().client)
await expect(adapter.list({search: 'token'})).resolves.toHaveLength(1)
await expect(adapter.list({search: 'deployment'})).resolves.toHaveLength(1)
await expect(adapter.list({search: 'nope'})).resolves.toHaveLength(0)
})
test('inspects a parameter through a server-side name filter', async () => {
const {client, sent} = runtimeStub({describe: {Parameters: [plain]}})
const resource = await new AwsParameterStoreAdapter(client).get('/floci/app/region')
const command = sent[0] as DescribeParametersCommand
expect(command).toBeInstanceOf(DescribeParametersCommand)
expect(command.input.ParameterFilters).toEqual([
{Key: 'Name', Option: 'Equals', Values: ['/floci/app/region']},
])
expect(resource?.id).toBe('/floci/app/region')
})
test('returns null when the name filter matches nothing', async () => {
const {client} = runtimeStub({describe: {Parameters: []}})
await expect(new AwsParameterStoreAdapter(client).get('/nope')).resolves.toBeNull()
})
test('returns null on ParameterNotFound even though the runtime answers 400', async () => {
// SSM reports a missing parameter as ParameterNotFound with HTTP 400, not
// 404, so matching on the status alone would rethrow instead of returning
// null and the inspector would show a runtime error for a missing row.
const {client} = stubSsm(() => {
throw Object.assign(new Error('Parameter /nope not found.'), {
name: 'ParameterNotFound',
$metadata: {httpStatusCode: 400},
})
})
await expect(new AwsParameterStoreAdapter(client).get('/nope')).resolves.toBeNull()
})
test('rethrows a failure that is not a missing parameter', async () => {
const {client} = stubSsm(() => {
throw Object.assign(new Error('AccessDenied'), {
name: 'AccessDeniedException',
$metadata: {httpStatusCode: 403},
})
})
await expect(new AwsParameterStoreAdapter(client).get('/floci/app/region')).rejects.toThrow('AccessDenied')
})
test('creates a parameter without Overwrite so an existing name is a conflict', async () => {
// Overwrite:true would silently replace a parameter the user did not mean
// to touch, which is data loss dressed up as a create.
const {client, sent} = runtimeStub({describe: {Parameters: [plain]}})
await new AwsParameterStoreAdapter(client).create({
values: {name: '/floci/app/region', value: 'eu-west-1', type: 'String', description: 'deployment region'},
})
const command = sent[0] as PutParameterCommand
expect(command).toBeInstanceOf(PutParameterCommand)
expect(command.input.Name).toBe('/floci/app/region')
expect(command.input.Value).toBe('eu-west-1')
expect(command.input.Type).toBe('String')
expect(command.input.Overwrite).toBeUndefined()
})
test('returns the created parameter without echoing the value back', async () => {
const {client} = runtimeStub({describe: {Parameters: [plain]}})
const resource = await new AwsParameterStoreAdapter(client).create({
values: {name: '/floci/app/region', value: 'eu-west-1', type: 'String'},
})
expect(resource.id).toBe('/floci/app/region')
expect(JSON.stringify(resource)).not.toContain('eu-west-1')
})
test('requires the fields the schema marks required', async () => {
const adapter = new AwsParameterStoreAdapter(runtimeStub().client)
const cases: Array<[Record<string, unknown>, string]> = [
[{}, 'name is required'],
[{name: '/floci/app/region'}, 'value is required'],
]
for (const [values, message] of cases) {
await expect(adapter.create({values})).rejects.toThrow(new ValidationError(message))
}
})
test('rejects a parameter type the schema does not offer', async () => {
const adapter = new AwsParameterStoreAdapter(runtimeStub().client)
await expect(
adapter.create({values: {name: '/a', value: 'b', type: 'Encrypted'}}),
).rejects.toThrow(ValidationError)
})
test('deletes a parameter by name', async () => {
const {client, sent} = stubSsm(() => ({}))
await new AwsParameterStoreAdapter(client).delete('/floci/app/region')
const command = sent[0] as DeleteParameterCommand
expect(command).toBeInstanceOf(DeleteParameterCommand)
expect(command.input.Name).toBe('/floci/app/region')
})
test('surfaces parameter tags under metadata', async () => {
const {client} = runtimeStub({
describe: {Parameters: [plain]},
tags: {TagList: [{Key: 'env', Value: 'prod'}]},
})
const [resource] = await new AwsParameterStoreAdapter(client).list()
expect(resource?.metadata.tags).toEqual([{key: 'env', value: 'prod'}])
})
test('still lists parameters when the tag lookup fails', async () => {
// list() builds every row through Promise.all, so an unisolated tag
// failure — throttling, transient IAM, a delete race — would reject the
// whole view and show nothing rather than metadata without tags.
const {client} = stubSsm((command) => {
if (command instanceof DescribeParametersCommand) return {Parameters: [plain]}
throw Object.assign(new Error('Rate exceeded'), {
name: 'ThrottlingException',
$metadata: {httpStatusCode: 400},
})
})
const [resource] = await new AwsParameterStoreAdapter(client).list()
expect(resource?.id).toBe('/floci/app/region')
expect(resource?.metadata.tags).toEqual([])
// Degraded, not silent: the UI can say tags could not be read.
expect(resource?.metadata.tagsUnavailable).toBe(true)
})
test('does not claim tags are unavailable when they are simply empty', async () => {
const {client} = runtimeStub({describe: {Parameters: [plain]}, tags: {TagList: []}})
const [resource] = await new AwsParameterStoreAdapter(client).list()
expect(resource?.metadata.tags).toEqual([])
expect(resource?.metadata.tagsUnavailable).toBeUndefined()
})
test('adds and removes tags against the Parameter resource type', async () => {
const {client, sent} = stubSsm(() => ({}))
await new AwsParameterStoreAdapter(client).updateTags('/floci/app/region', {env: 'prod', stale: null})
const added = sent.find((c) => c instanceof AddTagsToResourceCommand) as AddTagsToResourceCommand
const removed = sent.find((c) => c instanceof RemoveTagsFromResourceCommand) as RemoveTagsFromResourceCommand
expect(added.input.ResourceType).toBe('Parameter')
expect(added.input.ResourceId).toBe('/floci/app/region')
expect(added.input.Tags).toEqual([{Key: 'env', Value: 'prod'}])
expect(removed.input.TagKeys).toEqual(['stale'])
})
test('skips the tag call when it has nothing to send', async () => {
const {client, sent} = stubSsm(() => ({}))
await new AwsParameterStoreAdapter(client).updateTags('/floci/app/region', {env: 'prod'})
expect(sent.some((c) => c instanceof RemoveTagsFromResourceCommand)).toBe(false)
})
})
@@ -0,0 +1,236 @@
import {
AddTagsToResourceCommand,
DeleteParameterCommand,
DescribeParametersCommand,
type DescribeParametersCommandInput,
ListTagsForResourceCommand,
type ParameterMetadata,
PutParameterCommand,
RemoveTagsFromResourceCommand,
type SSMClient,
} from '@aws-sdk/client-ssm'
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import {awsParametersSchema, PARAMETER_NAME_MAX_LENGTH, PARAMETER_TYPES} from '../cloud-spi/parametersSchema'
import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
type ParameterTag = {key: string; value: string}
/** Tags plus whether the lookup itself failed, so the two stay distinguishable. */
type TagLookup = {tags: ParameterTag[]; unavailable?: true}
/**
* SSM reports a missing parameter as `ParameterNotFound` with HTTP **400**, not
* 404, so `get` matches on the error name. Matching the status instead would
* rethrow and show a runtime error where the console expects an empty result.
*/
const NOT_FOUND_NAME = 'ParameterNotFound'
export class AwsParameterStoreAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'parameters' as const
constructor(private readonly ssm: SSMClient) {}
schema(): ServiceSchema {
return awsParametersSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const parameters = await this.describeAll()
const resources = await Promise.all(parameters.map((parameter) => this.toResource(parameter)))
return filterBySearch(resources, query.search)
}
async get(id: string): Promise<CloudResource | null> {
try {
const [parameter] = await this.describe({
ParameterFilters: [{Key: 'Name', Option: 'Equals', Values: [id]}],
})
return parameter ? await this.toResource(parameter) : null
} catch (error) {
if (isNotFound(error)) return null
throw error
}
}
/**
* Creates without `Overwrite`, so an existing name fails as a conflict rather
* than silently replacing a parameter the caller did not mean to touch.
*/
async create(input: CreateResourceInput): Promise<CloudResource> {
const name = requiredString(input.values.name, 'name')
if (name.length > PARAMETER_NAME_MAX_LENGTH) {
throw new ValidationError(`name must be ${PARAMETER_NAME_MAX_LENGTH} characters or fewer`)
}
const value = requiredString(input.values.value, 'value')
const type = oneOf(input.values.type, PARAMETER_TYPES, 'type')
const description = optionalString(input.values.description, 'description')
await this.ssm.send(
new PutParameterCommand({
Name: name,
Value: value,
Type: type,
...(description ? {Description: description} : {}),
}),
)
// Read the metadata back rather than echoing the input, so the returned
// resource reflects the runtime — and carries no value.
const created = await this.get(name)
if (!created) throw new RuntimeError(`Parameter ${name} was created but could not be read back`)
return created
}
async delete(id: string): Promise<void> {
await this.ssm.send(new DeleteParameterCommand({Name: id}))
}
async updateTags(id: string, tags: Record<string, string | null>): Promise<void> {
const added = Object.entries(tags).filter((entry): entry is [string, string] => entry[1] !== null)
const removed = Object.keys(tags).filter((key) => tags[key] === null)
if (added.length > 0) {
await this.ssm.send(
new AddTagsToResourceCommand({
ResourceType: 'Parameter',
ResourceId: id,
Tags: added.map(([key, value]) => ({Key: key, Value: value})),
}),
)
}
if (removed.length > 0) {
await this.ssm.send(
new RemoveTagsFromResourceCommand({ResourceType: 'Parameter', ResourceId: id, TagKeys: removed}),
)
}
}
/**
* The local runtime ignores MaxResults and returns everything in one response,
* so this loop is only exercised by tests. Real SSM pages at 10.
*/
private async describeAll(): Promise<ParameterMetadata[]> {
const parameters: ParameterMetadata[] = []
let token: string | undefined
do {
const page = await this.ssm.send(
new DescribeParametersCommand(token ? {NextToken: token} : {}),
)
parameters.push(...(page.Parameters ?? []))
token = page.NextToken
} while (token)
return parameters
}
private async describe(input: DescribeParametersCommandInput): Promise<ParameterMetadata[]> {
const res = await this.ssm.send(new DescribeParametersCommand(input))
return res.Parameters ?? []
}
private async toResource(parameter: ParameterMetadata): Promise<CloudResource> {
const name = parameter.Name ?? ''
return toResource(parameter, await this.getTags(name))
}
/**
* Tags are enrichment, so a failure here degrades the row instead of failing
* the request. `list` builds every row through `Promise.all`, so an unisolated
* throttle or a delete race would reject the whole view and show nothing.
*
* The failure is reported as `tagsUnavailable` rather than swallowed, so an
* empty tag list and an unreadable one stay distinguishable.
*/
private async getTags(name: string): Promise<TagLookup> {
try {
const res = await this.ssm.send(
new ListTagsForResourceCommand({ResourceType: 'Parameter', ResourceId: name}),
)
return {tags: (res.TagList ?? []).map((tag) => ({key: tag.Key ?? '', value: tag.Value ?? ''}))}
} catch {
return {tags: [], unavailable: true}
}
}
}
/**
* Builds the resource **without the parameter value**, deliberately.
*
* `metadata` reaches the inspector, the React Query cache and telemetry, so a
* value must never travel with the resource — the same rule both secrets
* adapters pin. It matters more here than it looks: the local runtime returns
* SecureString plaintext even when `WithDecryption` is false, so relying on the
* runtime to withhold it would leak the secret. The adapter therefore never
* issues GetParameter at all.
*/
function toResource(parameter: ParameterMetadata, tagLookup: TagLookup): CloudResource {
const name = parameter.Name ?? ''
return {
id: name,
name,
cloud: 'aws',
service: 'parameters',
type: 'parameter',
// Parameter Store is regional but DescribeParameters does not echo the
// region, and inventing one would be fake data.
region: null,
createdAt: parameter.LastModifiedDate ? parameter.LastModifiedDate.toISOString() : null,
status: null,
metadata: {
parameterType: parameter.Type,
version: parameter.Version,
description: parameter.Description,
dataType: parameter.DataType,
tier: parameter.Tier,
allowedPattern: parameter.AllowedPattern,
lastModifiedUser: parameter.LastModifiedUser,
/** Tells the inspector the blank value is a policy, not a gap. */
valueWithheld: true,
tags: tagLookup.tags,
...(tagLookup.unavailable ? {tagsUnavailable: true} : {}),
},
}
}
/** Matches on the name and the description, since a name is often a long path. */
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter((resource) => {
const description = String(resource.metadata.description ?? '').toLowerCase()
return resource.name.toLowerCase().includes(normalized) || description.includes(normalized)
})
}
function isNotFound(error: unknown): boolean {
return typeof error === 'object' && error !== null && (error as {name?: string}).name === NOT_FOUND_NAME
}
function requiredString(value: unknown, field: string): string {
const raw = optionalString(value, field)
if (raw === undefined) throw new ValidationError(`${field} is required`)
return raw
}
function optionalString(value: unknown, field: string): string | undefined {
if (value === undefined || value === null || value === '') return undefined
if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`)
return value
}
function oneOf<T extends readonly string[]>(value: unknown, allowed: T, field: string): T[number] {
const raw = optionalString(value, field)
if (raw === undefined) return allowed[0] as T[number]
if (!allowed.includes(raw)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
return raw as T[number]
}
@@ -0,0 +1,143 @@
import {describe, expect, test} from 'bun:test'
import {
CreateSecretCommand,
DeleteSecretCommand,
DescribeSecretCommand,
ListSecretsCommand,
type SecretsManagerClient,
} from '@aws-sdk/client-secrets-manager'
import {AwsSecretsAdapter} from './AwsSecretsAdapter'
import {ValidationError} from '../cloud-spi/errors'
const CREATED = new Date('2026-07-01T10:00:00.000Z')
function stubSecrets(options: {pages?: Array<{SecretList: object[]; NextToken?: string}>; missing?: boolean} = {}) {
const sent: object[] = []
let page = 0
const client = {
async send(command: object) {
sent.push(command)
if (command instanceof ListSecretsCommand) {
const pages = options.pages ?? [{
SecretList: [{
Name: 'db-password',
ARN: 'arn:aws:secretsmanager:::db-password',
CreatedDate: CREATED,
DeletedDate: new Date('2026-08-01T10:00:00.000Z'),
KmsKeyId: 'alias/floci',
Tags: [{Key: 'environment', Value: 'test'}],
}],
}]
return pages[page++] ?? {SecretList: []}
}
if (command instanceof DescribeSecretCommand) {
if (options.missing) {
const err = new Error("Secrets Manager can't find the specified secret.")
err.name = 'ResourceNotFoundException'
throw err
}
return {Name: 'db-password', ARN: 'arn:aws:secretsmanager:::db-password', CreatedDate: CREATED, Description: 'primary db'}
}
if (command instanceof CreateSecretCommand) return {Name: command.input.Name, ARN: 'arn:new'}
return {}
},
} as unknown as SecretsManagerClient
return {client, sent}
}
describe('AwsSecretsAdapter', () => {
test('identifies itself as the AWS secrets adapter', () => {
const adapter = new AwsSecretsAdapter(stubSecrets().client)
expect(adapter.cloud).toBe('aws')
expect(adapter.service).toBe('secrets')
})
test('lists secret metadata and retains recovery state and KMS metadata', async () => {
const [resource] = await new AwsSecretsAdapter(stubSecrets().client).list()
expect(resource).toMatchObject({id: 'db-password', name: 'db-password', type: 'secret', service: 'secrets', status: 'deleted'})
expect(resource?.createdAt).toBe(CREATED.toISOString())
expect(resource?.metadata).toMatchObject({
arn: 'arn:aws:secretsmanager:::db-password',
kmsKeyId: 'alias/floci',
tags: [{key: 'environment', value: 'test'}],
})
})
test('never exposes a secret value', async () => {
const {client, sent} = stubSecrets()
const adapter = new AwsSecretsAdapter(client)
const [listed] = await adapter.list()
const inspected = await adapter.get('db-password')
for (const resource of [listed, inspected]) {
const serialized = JSON.stringify(resource)
expect(serialized).not.toContain('SecretString')
expect(serialized).not.toContain('SecretBinary')
for (const key of Object.keys(resource?.metadata ?? {})) {
expect(key.toLowerCase()).not.toContain('value')
}
}
expect(sent.every((command) => command.constructor.name !== 'GetSecretValueCommand')).toBe(true)
})
test('follows pagination', async () => {
const {client} = stubSecrets({
pages: [
{SecretList: [{Name: 'one'}], NextToken: 'next'},
{SecretList: [{Name: 'two'}]},
],
})
await expect(new AwsSecretsAdapter(client).list()).resolves.toHaveLength(2)
})
test('filters the list by search term', async () => {
const adapter = new AwsSecretsAdapter(stubSecrets().client)
await expect(adapter.list({search: 'db'})).resolves.toHaveLength(1)
await expect(adapter.list({search: 'nope'})).resolves.toHaveLength(0)
})
test('returns null for a missing secret', async () => {
const {client} = stubSecrets({missing: true})
await expect(new AwsSecretsAdapter(client).get('nope')).resolves.toBeNull()
})
test('requires a secret name', async () => {
await expect(new AwsSecretsAdapter(stubSecrets().client).create({values: {}}))
.rejects.toBeInstanceOf(ValidationError)
})
test('creates with an optional description', async () => {
const {client, sent} = stubSecrets()
await new AwsSecretsAdapter(client).create({values: {secretName: 'api-key', description: 'third party'}})
const command = sent[0] as CreateSecretCommand
expect(command.input.Name).toBe('api-key')
expect(command.input.Description).toBe('third party')
})
test('forwards an initial value verbatim but never echoes it back', async () => {
const {client, sent} = stubSecrets()
const value = ' s3cr3t '
const resource = await new AwsSecretsAdapter(client).create({values: {secretName: 'api-key', secretValue: value}})
expect((sent[0] as CreateSecretCommand).input.SecretString).toBe(value)
expect(JSON.stringify(resource)).not.toContain(value)
})
test('omits SecretString when no value was supplied', async () => {
const {client, sent} = stubSecrets()
await new AwsSecretsAdapter(client).create({values: {secretName: 'api-key'}})
expect((sent[0] as CreateSecretCommand).input.SecretString).toBeUndefined()
})
test('force-deletes so the secret does not linger in a recovery window', async () => {
const {client, sent} = stubSecrets()
await new AwsSecretsAdapter(client).delete('api-key')
const command = sent[0] as DeleteSecretCommand
expect(command.input.SecretId).toBe('api-key')
expect(command.input.ForceDeleteWithoutRecovery).toBe(true)
})
})
@@ -0,0 +1,148 @@
import {
CreateSecretCommand,
DeleteSecretCommand,
DescribeSecretCommand,
ListSecretsCommand,
type SecretsManagerClient,
} from '@aws-sdk/client-secrets-manager'
import {secretsManager as defaultSecretsManager} from '../aws'
import {ValidationError} from '../cloud-spi/errors'
import {awsSecretsSchema} from '../cloud-spi/secretsSchema'
import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
/**
* Secrets Manager as a Cloud Explorer service, so the console can count secrets
* instead of falling back to a dash.
*
* Deliberately metadata-only: this file imports ListSecrets and DescribeSecret
* and NOT GetSecretValue, so no code path here can return a secret value. That
* is a structural guarantee rather than a convention — adding value disclosure
* would require adding an import.
*
* Create accepts an optional write-only value. It is sent to the runtime once,
* but is never read back or placed on a resource.
*/
export class AwsSecretsAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'secrets' as const
constructor(private readonly client: SecretsManagerClient = defaultSecretsManager) {}
schema(): ServiceSchema {
return awsSecretsSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const resources: CloudResource[] = []
let nextToken: string | undefined
// Paginated: a single ListSecrets page caps at 100, and an unpaginated
// count silently under-reports past that.
do {
const res = await this.client.send(new ListSecretsCommand({NextToken: nextToken}))
for (const secret of res.SecretList ?? []) {
resources.push(toResource(secret))
}
nextToken = res.NextToken
} while (nextToken)
return filterBySearch(resources, query.search)
}
async get(id: string): Promise<CloudResource | null> {
try {
const res = await this.client.send(new DescribeSecretCommand({SecretId: id}))
return toResource(res)
} catch (error) {
if (isNotFound(error)) return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const name = stringValue(input.values.secretName ?? input.values.name)
const description = stringValue(input.values.description)
const secretValue = optionalSecretValue(input.values.secretValue)
if (!name) throw new ValidationError('secretName is required')
const res = await this.client.send(new CreateSecretCommand({
Name: name,
...(description ? {Description: description} : {}),
...(secretValue !== undefined ? {SecretString: secretValue} : {}),
}))
return toResource({Name: res.Name ?? name, ARN: res.ARN})
}
async delete(id: string): Promise<void> {
// A generic delete action cannot ask for a recovery window. Immediate
// deletion avoids leaving a resource visibly present after deletion.
await this.client.send(new DeleteSecretCommand({SecretId: id, ForceDeleteWithoutRecovery: true}))
}
}
interface SecretMetadata {
Name?: string
ARN?: string
CreatedDate?: Date
LastChangedDate?: Date
DeletedDate?: Date
Description?: string
RotationEnabled?: boolean
KmsKeyId?: string
LastAccessedDate?: Date
Tags?: {Key?: string; Value?: string}[]
}
function toResource(secret: SecretMetadata): CloudResource {
const name = secret.Name ?? ''
return {
id: name,
name,
cloud: 'aws',
service: 'secrets',
type: 'secret',
region: null,
createdAt: secret.CreatedDate?.toISOString() ?? null,
// A deleted secret lingers in the list during its recovery window, so the
// status has to distinguish it rather than reading as healthy.
status: secret.DeletedDate ? 'deleted' : 'active',
metadata: {
arn: secret.ARN,
description: secret.Description,
rotationEnabled: secret.RotationEnabled ?? false,
kmsKeyId: secret.KmsKeyId,
lastChangedDate: secret.LastChangedDate?.toISOString() ?? null,
lastAccessedDate: secret.LastAccessedDate?.toISOString() ?? null,
tags: (secret.Tags ?? []).map((tag) => ({key: tag.Key, value: tag.Value})),
},
}
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function optionalSecretValue(value: unknown): string | undefined {
// Whitespace is valid secret content. Treat only an empty input as absent.
return typeof value === 'string' && value.length > 0 ? value : undefined
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
}
function isNotFound(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false
const e = error as {name?: string; $metadata?: {httpStatusCode?: number}}
return e.name === 'ResourceNotFoundException' || e.$metadata?.httpStatusCode === 404
}
@@ -0,0 +1,275 @@
import {describe, expect, test} from 'bun:test'
import {
CreateFunctionCommand,
DeleteFunctionCommand,
GetFunctionCommand,
InvokeCommand,
type LambdaClient,
ListFunctionsCommand,
} from '@aws-sdk/client-lambda'
import {AwsServerlessAdapter} from './AwsServerlessAdapter'
import {ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
/** Minimal LambdaClient stub that records the commands it was sent. */
function stubLambda(handler: (command: object) => SendResult | Promise<SendResult>) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
return handler(command)
},
} as unknown as LambdaClient
return {client, sent}
}
const listPayload = {
Functions: [
{
FunctionName: 'hello',
FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:hello',
Runtime: 'nodejs20.x',
Handler: 'index.handler',
LastModified: '2026-07-01T10:00:00.000+0000',
State: 'Active',
MemorySize: 128,
Timeout: 3,
},
],
}
describe('AwsServerlessAdapter', () => {
test('identifies itself as the AWS serverless adapter', () => {
const {client} = stubLambda(() => ({}))
const adapter = new AwsServerlessAdapter(client)
expect(adapter.cloud).toBe('aws')
expect(adapter.service).toBe('serverless')
expect(adapter.schema().displayName).toBe('AWS Lambda')
})
test('lists functions and exposes runtime detail under metadata', async () => {
const {client, sent} = stubLambda(() => listPayload)
const [resource] = await new AwsServerlessAdapter(client).list()
expect(sent[0]).toBeInstanceOf(ListFunctionsCommand)
expect(resource).toMatchObject({
id: 'hello',
name: 'hello',
cloud: 'aws',
service: 'serverless',
type: 'lambda',
status: 'Active',
})
// The serverless schema reads these through metadata paths.
expect(resource?.metadata.runtime).toBe('nodejs20.x')
expect(resource?.metadata.lastModified).toBe('2026-07-01T10:00:00.000+0000')
})
test('filters the list by search term', async () => {
const {client} = stubLambda(() => listPayload)
const adapter = new AwsServerlessAdapter(client)
await expect(adapter.list({search: 'hell'})).resolves.toHaveLength(1)
await expect(adapter.list({search: 'nope'})).resolves.toHaveLength(0)
})
test('returns null when a function does not exist', async () => {
const {client} = stubLambda(() => {
throw Object.assign(new Error('ResourceNotFoundException'), {$metadata: {httpStatusCode: 404}})
})
await expect(new AwsServerlessAdapter(client).get('missing')).resolves.toBeNull()
})
test('rethrows a non-404 failure from get', async () => {
const {client} = stubLambda(() => {
throw Object.assign(new Error('AccessDenied'), {$metadata: {httpStatusCode: 403}})
})
await expect(new AwsServerlessAdapter(client).get('hello')).rejects.toThrow('AccessDenied')
})
test('inspects a function through GetFunction', async () => {
const {client, sent} = stubLambda(() => ({
Configuration: {FunctionName: 'hello', Runtime: 'nodejs20.x', Role: 'arn:aws:iam::000000000000:role/lambda'},
}))
const resource = await new AwsServerlessAdapter(client).get('hello')
expect(sent[0]).toBeInstanceOf(GetFunctionCommand)
expect(resource?.id).toBe('hello')
expect(resource?.metadata.role).toBe('arn:aws:iam::000000000000:role/lambda')
})
test('requires the fields the schema marks required', async () => {
const {client} = stubLambda(() => ({}))
const adapter = new AwsServerlessAdapter(client)
const cases: Array<[Record<string, unknown>, string]> = [
[{}, 'functionName is required'],
[{functionName: 'a'}, 'runtime is required'],
[{functionName: 'a', runtime: 'nodejs20.x'}, 'handler is required'],
[{functionName: 'a', runtime: 'nodejs20.x', handler: 'index.handler'}, 'role is required'],
]
for (const [values, message] of cases) {
await expect(adapter.create({values})).rejects.toThrow(new ValidationError(message))
}
})
test('creates a function with a default inline handler', async () => {
const {client, sent} = stubLambda(() => ({FunctionName: 'hello', State: 'Pending'}))
await new AwsServerlessAdapter(client).create({
values: {functionName: 'hello', runtime: 'nodejs20.x', handler: 'index.handler', role: 'arn:role'},
})
const command = sent[0] as CreateFunctionCommand
expect(command).toBeInstanceOf(CreateFunctionCommand)
expect(command.input.FunctionName).toBe('hello')
expect(command.input.Architectures).toEqual(['x86_64'])
expect(command.input.MemorySize).toBe(128)
expect(command.input.Timeout).toBe(3)
expect(command.input.Code?.ZipFile).toBeInstanceOf(Uint8Array)
})
test('passes the selected architecture to Lambda', async () => {
const {client, sent} = stubLambda(() => ({FunctionName: 'hello'}))
await new AwsServerlessAdapter(client).create({
values: {
functionName: 'hello',
runtime: 'nodejs22.x',
architecture: 'arm64',
handler: 'index.handler',
role: 'arn:role',
},
})
expect((sent[0] as CreateFunctionCommand).input.Architectures).toEqual(['arm64'])
})
test('rejects an unsupported architecture', async () => {
const {client} = stubLambda(() => ({}))
await expect(new AwsServerlessAdapter(client).create({
values: {
functionName: 'hello',
runtime: 'nodejs22.x',
architecture: 'ppc64',
handler: 'index.handler',
role: 'arn:role',
},
})).rejects.toThrow(new ValidationError('architecture must be x86_64 or arm64'))
})
test('uses Lambda defaults for blank optional numeric fields', async () => {
const {client, sent} = stubLambda(() => ({FunctionName: 'hello'}))
await new AwsServerlessAdapter(client).create({
values: {
functionName: 'hello',
runtime: 'nodejs22.x',
handler: 'index.handler',
role: 'arn:role',
memorySize: '',
timeout: '',
},
})
const command = sent[0] as CreateFunctionCommand
expect(command.input.MemorySize).toBe(128)
expect(command.input.Timeout).toBe(3)
})
test('uses an uploaded ZIP deployment package when supplied', async () => {
const {client, sent} = stubLambda(() => ({FunctionName: 'hello'}))
const zip = new Uint8Array([0x50, 0x4b, 0x03, 0x04])
await new AwsServerlessAdapter(client).create({
values: {
functionName: 'hello',
runtime: 'nodejs22.x',
handler: 'index.handler',
role: 'arn:role',
zipFile: Buffer.from(zip).toString('base64'),
},
})
const command = sent[0] as CreateFunctionCommand
expect(command.input.Code?.ZipFile).toEqual(zip)
})
test('rejects an invalid uploaded ZIP encoding', async () => {
const {client} = stubLambda(() => ({}))
await expect(new AwsServerlessAdapter(client).create({
values: {
functionName: 'hello',
runtime: 'nodejs22.x',
handler: 'index.handler',
role: 'arn:role',
zipFile: 'not base64!',
},
})).rejects.toThrow(new ValidationError('zipFile must be a base64-encoded ZIP archive'))
})
test('deletes a function by name', async () => {
const {client, sent} = stubLambda(() => ({}))
await new AwsServerlessAdapter(client).delete('hello')
expect((sent[0] as DeleteFunctionCommand).input.FunctionName).toBe('hello')
})
describe('invoke', () => {
test('returns the decoded payload, status and duration', async () => {
const {client, sent} = stubLambda(() => ({
StatusCode: 200,
Payload: new TextEncoder().encode('{"ok":true}'),
}))
const result = await new AwsServerlessAdapter(client).invoke('hello', '{"a":1}')
const command = sent[0] as InvokeCommand
expect(command).toBeInstanceOf(InvokeCommand)
expect(command.input.FunctionName).toBe('hello')
expect(command.input.LogType).toBe('Tail')
expect(new TextDecoder().decode(command.input.Payload as Uint8Array)).toBe('{"a":1}')
expect(result.statusCode).toBe(200)
expect(result.payload).toBe('{"ok":true}')
expect(result.executionDuration).toBeGreaterThanOrEqual(0)
expect(result.functionError).toBeUndefined()
})
test('defaults an empty payload to an empty JSON object', async () => {
const {client, sent} = stubLambda(() => ({StatusCode: 200}))
await new AwsServerlessAdapter(client).invoke('hello', '')
const command = sent[0] as InvokeCommand
expect(new TextDecoder().decode(command.input.Payload as Uint8Array)).toBe('{}')
})
test('surfaces a handler error alongside the response payload', async () => {
const {client} = stubLambda(() => ({
StatusCode: 200,
FunctionError: 'Unhandled',
Payload: new TextEncoder().encode('{"errorMessage":"boom"}'),
}))
const result = await new AwsServerlessAdapter(client).invoke('hello', '{}')
expect(result.functionError).toBe('Unhandled')
expect(result.payload).toContain('boom')
})
test('decodes the base64 tailed log', async () => {
const {client} = stubLambda(() => ({
StatusCode: 200,
LogResult: Buffer.from('START RequestId: abc\nEND', 'utf8').toString('base64'),
}))
const result = await new AwsServerlessAdapter(client).invoke('hello', '{}')
expect(result.logResult).toContain('START RequestId: abc')
})
test('handles a missing payload without throwing', async () => {
const {client} = stubLambda(() => ({StatusCode: 202}))
const result = await new AwsServerlessAdapter(client).invoke('hello', '{}')
expect(result.statusCode).toBe(202)
expect(result.payload).toBe('')
})
})
})
@@ -0,0 +1,244 @@
import {ValidationError} from '../cloud-spi/errors'
import {
CreateFunctionCommand,
DeleteFunctionCommand,
GetFunctionCommand,
InvokeCommand,
type InvokeCommandOutput,
ListFunctionsCommand,
type LambdaClient,
} from "@aws-sdk/client-lambda";
import { awsServerlessSchema } from "../cloud-spi/serverlessSchema";
import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
ResourceQuery,
ServerlessInvokeResult,
ServiceSchema,
} from "../cloud-spi/types";
import { lambda as defaultLambda } from "../aws";
import { createZipArchive, handlerFileName } from "./zipArchive";
import {lambdaStarterCode} from '../cloud-spi/lambdaStarterCode'
export class AwsServerlessAdapter implements CloudServiceAdapter {
readonly cloud = "aws" as const;
readonly service = "serverless" as const;
constructor(private readonly lambda: LambdaClient = defaultLambda) {}
schema(): ServiceSchema {
return awsServerlessSchema();
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const res = await this.lambda.send(new ListFunctionsCommand({}));
const resources: CloudResource[] = (res.Functions ?? []).map((fn) => ({
id: fn.FunctionName ?? fn.FunctionArn ?? "",
name: fn.FunctionName ?? "",
cloud: "aws" as const,
service: "serverless" as const,
type: "lambda",
region: null,
createdAt: null,
status: fn.State ?? null,
metadata: {
arn: fn.FunctionArn,
runtime: fn.Runtime,
handler: fn.Handler,
lastModified: fn.LastModified,
memorySize: fn.MemorySize,
timeout: fn.Timeout,
codeSize: fn.CodeSize,
packageType: fn.PackageType,
description: fn.Description,
},
}));
return filterBySearch(resources, query.search);
}
async get(id: string): Promise<CloudResource | null> {
try {
const res = await this.lambda.send(
new GetFunctionCommand({ FunctionName: id }),
);
const config = res.Configuration;
if (!config) return null;
return {
id: config.FunctionName ?? config.FunctionArn ?? id,
name: config.FunctionName ?? id,
cloud: "aws",
service: "serverless",
type: "lambda",
region: null,
createdAt: null,
status: config.State ?? null,
metadata: {
arn: config.FunctionArn,
runtime: config.Runtime,
handler: config.Handler,
lastModified: config.LastModified,
memorySize: config.MemorySize,
timeout: config.Timeout,
codeSize: config.CodeSize,
packageType: config.PackageType,
description: config.Description,
role: config.Role,
version: config.Version,
codeSha256: config.CodeSha256,
environment: config.Environment?.Variables,
},
};
} catch (error) {
if (hasHttpStatus(error, 404)) return null;
throw error;
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const values = input.values;
const functionName = String(
values.functionName ?? values.name ?? "",
).trim();
const runtime = String(values.runtime ?? "").trim();
const architecture = String(values.architecture ?? 'x86_64').trim();
const handler = String(values.handler ?? "").trim();
const role = String(values.role ?? "").trim();
const description = String(values.description ?? "").trim();
const memorySize = positiveNumberOrDefault(values.memorySize, 128);
const timeout = positiveNumberOrDefault(values.timeout, 3);
const zipFile = String(values.zipFile ?? '').trim();
const code =
String(values.code ?? "").trim() ||
lambdaStarterCode(runtime)
if (!functionName) throw new ValidationError("functionName is required");
if (!runtime) throw new ValidationError("runtime is required");
if (architecture !== 'x86_64' && architecture !== 'arm64') {
throw new ValidationError('architecture must be x86_64 or arm64')
}
if (!handler) throw new ValidationError("handler is required");
if (!role) throw new ValidationError("role is required");
const res = await this.lambda.send(
new CreateFunctionCommand({
FunctionName: functionName,
Runtime: runtime as never,
Architectures: [architecture as never],
Handler: handler,
Role: role,
Description: description || undefined,
MemorySize: memorySize,
Timeout: timeout,
Code: {
// The browser submits uploaded files as base64. Otherwise package the
// editor source into the same AWS ZIP payload.
ZipFile: zipFile
? decodeBase64Zip(zipFile)
: createZipArchive([
{ name: handlerFileName(handler, runtime), content: code },
]),
},
}),
);
return {
id: res.FunctionName ?? res.FunctionArn ?? functionName,
name: res.FunctionName ?? functionName,
cloud: "aws",
service: "serverless",
type: "lambda",
region: null,
createdAt: null,
status: res.State ?? null,
metadata: {
arn: res.FunctionArn,
runtime: res.Runtime,
handler: res.Handler,
lastModified: res.LastModified,
memorySize: res.MemorySize,
timeout: res.Timeout,
codeSize: res.CodeSize,
packageType: res.PackageType,
description: res.Description,
role: res.Role,
version: res.Version,
codeSha256: res.CodeSha256,
},
};
}
async delete(id: string): Promise<void> {
await this.lambda.send(new DeleteFunctionCommand({ FunctionName: id }));
}
async invoke(id: string, payload: string): Promise<ServerlessInvokeResult> {
const startedAt = performance.now();
const res = await this.lambda.send(
new InvokeCommand({
FunctionName: id,
Payload: new TextEncoder().encode(payload || "{}"),
// Tail returns the last 4 KB of the execution log, base64 encoded.
LogType: "Tail",
}),
);
const executionDuration = Math.round(performance.now() - startedAt);
return {
statusCode: res.StatusCode ?? 0,
payload: decodePayload(res.Payload),
...(res.FunctionError ? { functionError: res.FunctionError } : {}),
...(res.LogResult ? { logResult: decodeLogResult(res.LogResult) } : {}),
executionDuration,
};
}
}
function decodeBase64Zip(value: string): Uint8Array {
if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
throw new ValidationError('zipFile must be a base64-encoded ZIP archive')
}
return new Uint8Array(Buffer.from(value, 'base64'))
}
function positiveNumberOrDefault(value: unknown, defaultValue: number): number {
if (value === undefined || value === null || String(value).trim() === '') {
return defaultValue
}
const number = Number(value)
return Number.isFinite(number) && number > 0 ? number : defaultValue
}
function decodePayload(payload: InvokeCommandOutput["Payload"]): string {
if (!payload) return "";
return new TextDecoder().decode(payload);
}
/** Lambda returns the tailed log base64 encoded; surface it as plain text. */
function decodeLogResult(logResult: string): string {
try {
return Buffer.from(logResult, "base64").toString("utf8");
} catch {
return logResult;
}
}
function filterBySearch(
resources: CloudResource[],
search?: string,
): CloudResource[] {
const normalized = search?.trim().toLowerCase();
if (!normalized) return resources;
return resources.filter((r) => r.name.toLowerCase().includes(normalized));
}
function hasHttpStatus(error: unknown, status: number): boolean {
if (typeof error !== "object" || error === null) return false;
const metadata = (error as { $metadata?: { httpStatusCode?: number } })
.$metadata;
return metadata?.httpStatusCode === status;
}
@@ -0,0 +1,99 @@
import {describe, expect, test} from 'bun:test'
import {AwsSesAdapter} from './AwsSesAdapter'
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: {'content-type': 'application/json'},
})
}
describe('AwsSesAdapter', () => {
test('maps captured Floci emails to normalized mailbox resources', async () => {
const adapter = new AwsSesAdapter('http://floci.test', async () => jsonResponse({
messages: [{
Id: 'message-1',
Region: 'us-east-1',
Source: 'sender@example.test',
Destination: {ToAddresses: ['recipient@example.test'], CcAddresses: ['copy@example.test']},
ReplyToAddresses: ['reply@example.test'],
Subject: 'Welcome',
Body: {text_part: 'Hello', html_part: '<strong>Hello</strong>'},
Timestamp: '2026-08-30T12:00:00.000Z',
}],
}))
const resources = await adapter.list()
expect(resources).toEqual([expect.objectContaining({
id: 'message-1',
name: 'Welcome',
cloud: 'aws',
service: 'email',
type: 'email',
region: 'us-east-1',
createdAt: '2026-08-30T12:00:00.000Z',
status: 'captured',
metadata: expect.objectContaining({
source: 'sender@example.test',
toAddresses: ['recipient@example.test'],
ccAddresses: ['copy@example.test'],
replyToAddresses: ['reply@example.test'],
textBody: 'Hello',
htmlBody: '<strong>Hello</strong>',
messageType: 'simple',
}),
})])
})
test('filters messages by subject, sender, and recipients', async () => {
const adapter = new AwsSesAdapter('http://floci.test', async () => jsonResponse({
messages: [
{Id: 'welcome', Source: 'sender@example.test', Destination: {ToAddresses: ['a@example.test']}, Subject: 'Welcome'},
{Id: 'invoice', Source: 'billing@example.test', Destination: {ToAddresses: ['customer@example.test']}, Subject: 'Invoice'},
],
}))
await expect(adapter.list({search: 'CUSTOMER'})).resolves.toMatchObject([{id: 'invoice'}])
await expect(adapter.list({search: 'sender'})).resolves.toMatchObject([{id: 'welcome'}])
})
test('gets one message through Floci inspection filtering', async () => {
let requestedUrl = ''
const adapter = new AwsSesAdapter('http://floci.test/', async (input) => {
requestedUrl = String(input)
return jsonResponse({messages: [{Id: 'message with space', Subject: 'Test'}]})
})
const message = await adapter.get('message with space')
expect(requestedUrl).toBe('http://floci.test/_aws/ses?id=message%20with%20space')
expect(message).toMatchObject({id: 'message with space', name: 'Test'})
})
test('clears the captured inbox through the Floci inspection endpoint', async () => {
let requestedUrl = ''
let method = ''
const adapter = new AwsSesAdapter('http://floci.test', async (input, init) => {
requestedUrl = String(input)
method = init?.method ?? ''
return new Response(null, {status: 200})
})
await adapter.clearEmailInbox()
expect(requestedUrl).toBe('http://floci.test/_aws/ses')
expect(method).toBe('DELETE')
})
test('returns the SES mailbox schema', () => {
const adapter = new AwsSesAdapter()
expect(adapter.schema()).toMatchObject({
cloud: 'aws',
service: 'email',
displayName: 'SES Mailbox',
actions: ['list', 'inspect'],
})
})
})
@@ -0,0 +1,154 @@
import {httpStatusToCloudError, NotSupportedError, RuntimeError, RuntimeUnavailableError} from '../cloud-spi/errors'
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
interface FlociSesMessage {
Id?: string
Region?: string | null
Source?: string
Destination?: {
ToAddresses?: string[]
CcAddresses?: string[]
BccAddresses?: string[]
}
ReplyToAddresses?: string[]
Subject?: string
Body?: {
text_part?: string | null
html_part?: string | null
}
RawData?: string
Timestamp?: string
}
interface FlociSesInbox {
messages?: FlociSesMessage[]
}
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
/**
* Adapts Floci's documented SES inspection surface to normalized email resources.
* It deliberately does not send email: applications under test keep using the
* AWS SES SDK, and this mailbox shows exactly what the runtime captured.
*/
export class AwsSesAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'email' as const
constructor(
private readonly endpoint = process.env.FLOCI_ENDPOINT ?? 'http://localhost:4566',
private readonly fetcher: FetchLike = globalThis.fetch,
) {}
schema(): ServiceSchema {
return awsSesEmailSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const messages = await this.fetchInbox()
const resources = messages.map(messageToResource)
return filterBySearch(resources, query.search)
}
async get(id: string): Promise<CloudResource | null> {
const messages = await this.fetchInbox(id)
const message = messages.find((entry) => entry.Id === id)
return message ? messageToResource(message) : null
}
async create(_input: CreateResourceInput): Promise<CloudResource> {
throw new NotSupportedError('Sending email from the mailbox is not implemented yet')
}
async delete(_id: string): Promise<void> {
throw new NotSupportedError('Deleting individual captured emails is not supported by Floci SES')
}
async clearEmailInbox(): Promise<void> {
await this.request('/_aws/ses', {method: 'DELETE'})
}
private async fetchInbox(id?: string): Promise<FlociSesMessage[]> {
const search = id ? `?id=${encodeURIComponent(id)}` : ''
const response = await this.request(`/_aws/ses${search}`, {method: 'GET'})
let body: FlociSesInbox
try {
body = await response.json() as FlociSesInbox
} catch (error) {
throw new RuntimeError('Floci SES inspection returned invalid JSON', {cause: error})
}
if (!Array.isArray(body.messages)) {
throw new RuntimeError('Floci SES inspection response did not contain messages')
}
return body.messages
}
private async request(path: string, init: RequestInit): Promise<Response> {
let response: Response
try {
response = await this.fetcher(`${this.endpoint.replace(/\/$/, '')}${path}`, init)
} catch (error) {
throw new RuntimeUnavailableError(`Cannot reach Floci SES at ${this.endpoint}`, {cause: error})
}
if (response.ok) return response
const detail = await response.text().catch(() => '')
throw httpStatusToCloudError(
response.status,
detail || `Floci SES inspection returned HTTP ${response.status}`,
)
}
}
function messageToResource(message: FlociSesMessage): CloudResource {
const id = message.Id ?? ''
const raw = typeof message.RawData === 'string'
return {
id,
name: message.Subject?.trim() || '(no subject)',
cloud: 'aws',
service: 'email',
type: 'email',
region: message.Region ?? null,
createdAt: message.Timestamp ?? null,
status: 'captured',
metadata: {
provider: 'aws',
emailService: 'ses',
source: message.Source ?? null,
toAddresses: message.Destination?.ToAddresses ?? [],
ccAddresses: message.Destination?.CcAddresses ?? [],
bccAddresses: message.Destination?.BccAddresses ?? [],
replyToAddresses: message.ReplyToAddresses ?? [],
textBody: message.Body?.text_part ?? null,
htmlBody: message.Body?.html_part ?? null,
rawData: message.RawData ?? null,
messageType: raw ? 'raw' : 'simple',
},
}
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter((resource) => {
const metadata = resource.metadata
const searchable = [
resource.name,
stringValue(metadata.source),
...stringList(metadata.toAddresses),
...stringList(metadata.ccAddresses),
...stringList(metadata.bccAddresses),
].join(' ').toLowerCase()
return searchable.includes(normalized)
})
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function stringList(value: unknown): string[] {
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
}
@@ -0,0 +1,244 @@
import {describe, expect, test} from 'bun:test'
import {
CreateQueueCommand,
DeleteQueueCommand,
GetQueueAttributesCommand,
GetQueueUrlCommand,
ListQueuesCommand,
type SQSClient,
} from '@aws-sdk/client-sqs'
import {AwsSqsAdapter} from './AwsSqsAdapter'
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
const BASE = 'http://localhost:4566/000000000000'
/** Attribute shape captured from Floci core. */
const ORDERS_ATTRIBUTES = {
QueueArn: 'arn:aws:sqs:us-east-1:000000000000:orders-queue',
ApproximateNumberOfMessages: '4',
ApproximateNumberOfMessagesNotVisible: '1',
VisibilityTimeout: '30',
MessageRetentionPeriod: '345600',
MaximumMessageSize: '1048576',
DelaySeconds: '0',
CreatedTimestamp: '1785177503',
LastModifiedTimestamp: '1785177503',
}
function stubSqs(handlers: {
queues?: string[]
attributes?: Record<string, string>
missingQueue?: boolean
} = {}) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
if (command instanceof ListQueuesCommand) {
return {QueueUrls: handlers.queues ?? [`${BASE}/orders-queue`]}
}
if (command instanceof GetQueueUrlCommand) {
if (handlers.missingQueue) {
const err = new Error('The specified queue does not exist')
err.name = 'QueueDoesNotExist'
throw err
}
return {QueueUrl: `${BASE}/${(command as GetQueueUrlCommand).input.QueueName}`}
}
if (command instanceof GetQueueAttributesCommand) {
return {Attributes: handlers.attributes ?? ORDERS_ATTRIBUTES}
}
if (command instanceof CreateQueueCommand) {
return {QueueUrl: `${BASE}/${(command as CreateQueueCommand).input.QueueName}`}
}
return {}
},
} as unknown as SQSClient
return {client, sent}
}
describe('AwsSqsAdapter', () => {
test('identifies itself as the AWS messaging adapter', () => {
const adapter = new AwsSqsAdapter(stubSqs().client)
expect(adapter.cloud).toBe('aws')
expect(adapter.service).toBe('messaging')
expect(adapter.schema().displayName).toBe('AWS SQS')
})
test('uses the queue name as the id, not the URL', async () => {
// A URL embeds the endpoint and changes if the runtime is re-pointed,
// which would make resource links unstable.
const {client} = stubSqs()
const [resource] = await new AwsSqsAdapter(client).list()
expect(resource?.id).toBe('orders-queue')
expect(resource?.name).toBe('orders-queue')
expect(resource?.metadata.queueUrl).toBe(`${BASE}/orders-queue`)
})
test('normalizes queue attributes', async () => {
const {client} = stubSqs()
const [resource] = await new AwsSqsAdapter(client).list()
expect(resource).toMatchObject({cloud: 'aws', service: 'messaging', type: 'queue'})
expect(resource?.metadata.arn).toBe('arn:aws:sqs:us-east-1:000000000000:orders-queue')
expect(resource?.metadata.approximateMessages).toBe(4)
expect(resource?.metadata.messagesInFlight).toBe(1)
expect(resource?.metadata.visibilityTimeout).toBe(30)
})
test('converts SQS epoch-second timestamps to ISO', async () => {
const {client} = stubSqs()
const [resource] = await new AwsSqsAdapter(client).list()
expect(resource?.createdAt).toBe(new Date(1785177503 * 1000).toISOString())
})
test('marks a FIFO queue with its own type', async () => {
const {client} = stubSqs({attributes: {...ORDERS_ATTRIBUTES, FifoQueue: 'true'}})
const [resource] = await new AwsSqsAdapter(client).list()
expect(resource?.type).toBe('fifo-queue')
})
test('still lists a queue whose attributes cannot be read', async () => {
// A describe failure should not blank the whole list.
const client = {
async send(command: object) {
if (command instanceof ListQueuesCommand) return {QueueUrls: [`${BASE}/orders-queue`]}
throw new Error('AccessDenied')
},
} as unknown as SQSClient
const [resource] = await new AwsSqsAdapter(client).list()
expect(resource?.id).toBe('orders-queue')
expect(resource?.metadata.arn).toBeUndefined()
})
test('handles an empty queue list', async () => {
const {client} = stubSqs({queues: []})
await expect(new AwsSqsAdapter(client).list()).resolves.toEqual([])
})
test('filters the list by search term', async () => {
const {client} = stubSqs({queues: [`${BASE}/orders-queue`, `${BASE}/dead-letter-queue`]})
const adapter = new AwsSqsAdapter(client)
await expect(adapter.list({search: 'orders'})).resolves.toHaveLength(1)
await expect(adapter.list({search: 'queue'})).resolves.toHaveLength(2)
})
test('resolves a name to a URL when inspecting', async () => {
const {client, sent} = stubSqs()
const resource = await new AwsSqsAdapter(client).get('orders-queue')
expect(sent[0]).toBeInstanceOf(GetQueueUrlCommand)
expect(resource?.id).toBe('orders-queue')
})
test('returns null for a queue that does not exist', async () => {
const {client} = stubSqs({missingQueue: true})
await expect(new AwsSqsAdapter(client).get('nope')).resolves.toBeNull()
})
test('creates a queue with optional attributes', async () => {
const {client, sent} = stubSqs()
await new AwsSqsAdapter(client).create({
values: {queueName: 'orders-queue', visibilityTimeout: '60', messageRetentionPeriod: '600'},
})
const command = sent[0] as CreateQueueCommand
expect(command).toBeInstanceOf(CreateQueueCommand)
expect(command.input.QueueName).toBe('orders-queue')
expect(command.input.Attributes).toEqual({VisibilityTimeout: '60', MessageRetentionPeriod: '600'})
})
test('omits the attributes block when nothing was supplied', async () => {
const {client, sent} = stubSqs()
await new AwsSqsAdapter(client).create({values: {queueName: 'orders-queue'}})
expect((sent[0] as CreateQueueCommand).input.Attributes).toBeUndefined()
})
test('requires a queue name', async () => {
const {client} = stubSqs()
await expect(new AwsSqsAdapter(client).create({values: {}})).rejects.toBeInstanceOf(ValidationError)
})
test('rejects a name SQS would refuse but accepts a FIFO name', async () => {
const adapter = new AwsSqsAdapter(stubSqs().client)
for (const name of ['has space', 'has/slash', 'a'.repeat(81), 'plain.name']) {
await expect(adapter.create({values: {queueName: name}})).rejects.toBeInstanceOf(ValidationError)
}
await expect(adapter.create({values: {queueName: 'orders.fifo'}})).resolves.toBeDefined()
})
test('sets FifoQueue when the name ends in .fifo', async () => {
// Real SQS rejects a .fifo create without this attribute. Floci core
// infers it from the suffix, so a stub or the emulator alone would not
// catch its absence.
const {client, sent} = stubSqs()
await new AwsSqsAdapter(client).create({values: {queueName: 'orders.fifo'}})
expect((sent[0] as CreateQueueCommand).input.Attributes).toEqual({FifoQueue: 'true'})
})
test('does not set FifoQueue for a standard queue', async () => {
const {client, sent} = stubSqs()
await new AwsSqsAdapter(client).create({values: {queueName: 'orders-queue'}})
expect((sent[0] as CreateQueueCommand).input.Attributes).toBeUndefined()
})
test('deletes a queue by resolving its URL first', async () => {
const {client, sent} = stubSqs()
await new AwsSqsAdapter(client).delete('orders-queue')
expect(sent[0]).toBeInstanceOf(GetQueueUrlCommand)
expect((sent[1] as DeleteQueueCommand).input.QueueUrl).toBe(`${BASE}/orders-queue`)
})
test('reports a missing queue on delete rather than silently succeeding', async () => {
const {client} = stubSqs({missingQueue: true})
await expect(new AwsSqsAdapter(client).delete('nope')).rejects.toBeInstanceOf(NotFoundError)
})
test('health lists queues without describing each one', async () => {
const {client, sent} = stubSqs()
await new AwsSqsAdapter(client).health()
expect(sent).toHaveLength(1)
expect(sent[0]).toBeInstanceOf(ListQueuesCommand)
})
/**
* Bounds are SQS's own. Without them a non-numeric value only fails at the
* CreateQueue call, so the form reports a runtime error instead of a field error.
*/
test('rejects out-of-range and non-numeric queue attributes', async () => {
const {client} = stubSqs()
const adapter = new AwsSqsAdapter(client)
await expect(
adapter.create({values: {queueName: 'q', visibilityTimeout: 'soon'}}),
).rejects.toThrow(ValidationError)
await expect(
adapter.create({values: {queueName: 'q', visibilityTimeout: '43201'}}),
).rejects.toThrow(ValidationError)
await expect(
adapter.create({values: {queueName: 'q', messageRetentionPeriod: '59'}}),
).rejects.toThrow(ValidationError)
})
test('accepts attributes inside the SQS range and omits blanks', async () => {
const {client, sent} = stubSqs()
await new AwsSqsAdapter(client).create({
values: {queueName: 'q', visibilityTimeout: '30', messageRetentionPeriod: ''},
})
const create = sent.find((command) => command instanceof CreateQueueCommand)
expect(create?.input.Attributes).toEqual({VisibilityTimeout: '30'})
})
})
@@ -0,0 +1,223 @@
import {
CreateQueueCommand,
DeleteQueueCommand,
GetQueueAttributesCommand,
GetQueueUrlCommand,
ListQueuesCommand,
type SQSClient,
} from '@aws-sdk/client-sqs'
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
import {awsMessagingSchema} from '../cloud-spi/messagingSchema'
import {sqs as defaultSqs} from '../aws'
import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
/**
* SQS queues as `messaging` resources.
*
* SQS identifies a queue by URL, but a URL is a poor id for the console: it is
* long, embeds the endpoint, and changes if the runtime is re-pointed. The
* adapter uses the queue name as the resource id and resolves it back to a URL
* per call via GetQueueUrl, which is the same thing the AWS console does.
*/
interface QueueAttributes {
QueueArn?: string
ApproximateNumberOfMessages?: string
ApproximateNumberOfMessagesNotVisible?: string
ApproximateNumberOfMessagesDelayed?: string
VisibilityTimeout?: string
MessageRetentionPeriod?: string
MaximumMessageSize?: string
DelaySeconds?: string
CreatedTimestamp?: string
LastModifiedTimestamp?: string
FifoQueue?: string
}
export class AwsSqsAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'messaging' as const
constructor(private readonly sqs: SQSClient = defaultSqs) {}
schema(): ServiceSchema {
return awsMessagingSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const res = await this.sqs.send(new ListQueuesCommand({}))
const urls = res.QueueUrls ?? []
const resources = await Promise.all(
urls.map(async (url) => {
const name = queueNameFromUrl(url)
// A queue with no attributes is still a queue, so a failed
// describe degrades to the bare row rather than failing the list.
const attributes = await this.attributesFor(url).catch(() => ({}))
return toResource(name, url, attributes)
}),
)
return filterBySearch(resources, query.search)
}
async get(id: string): Promise<CloudResource | null> {
const url = await this.queueUrl(id)
if (!url) return null
return toResource(id, url, await this.attributesFor(url))
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const name = stringValue(input.values.queueName ?? input.values.name)
if (!name) throw new ValidationError('queueName is required')
if (!isValidQueueName(name)) {
throw new ValidationError(
'Use a valid SQS queue name: up to 80 alphanumeric characters, hyphens, or underscores.',
)
}
const attributes: Record<string, string> = {}
// Bounds are SQS's own, so a bad value fails in the form rather than as a
// 400 from the runtime several seconds later.
const visibilityTimeout = numericAttribute(input.values.visibilityTimeout, 'visibilityTimeout', 0, 43_200)
const retention = numericAttribute(input.values.messageRetentionPeriod, 'messageRetentionPeriod', 60, 1_209_600)
if (visibilityTimeout) attributes.VisibilityTimeout = visibilityTimeout
if (retention) attributes.MessageRetentionPeriod = retention
// A .fifo name alone is not enough: real SQS rejects the create unless
// FifoQueue is set. Floci core infers it from the suffix, so omitting it
// works locally and then fails against AWS.
if (isFifoName(name)) attributes.FifoQueue = 'true'
const res = await this.sqs.send(
new CreateQueueCommand({
QueueName: name,
...(Object.keys(attributes).length > 0 ? {Attributes: attributes} : {}),
}),
)
const url = res.QueueUrl ?? (await this.queueUrl(name)) ?? ''
return toResource(name, url, await this.attributesFor(url).catch(() => ({})))
}
async delete(id: string): Promise<void> {
const url = await this.queueUrl(id)
if (!url) throw new NotFoundError(`Queue ${id} does not exist`)
await this.sqs.send(new DeleteQueueCommand({QueueUrl: url}))
}
/** Listing queue names is cheaper than describing each one. */
async health(): Promise<void> {
await this.sqs.send(new ListQueuesCommand({}))
}
private async queueUrl(name: string): Promise<string | null> {
try {
const res = await this.sqs.send(new GetQueueUrlCommand({QueueName: name}))
return res.QueueUrl ?? null
} catch (error) {
if (isQueueMissing(error)) return null
throw error
}
}
private async attributesFor(url: string): Promise<QueueAttributes> {
if (!url) return {}
const res = await this.sqs.send(
new GetQueueAttributesCommand({QueueUrl: url, AttributeNames: ['All']}),
)
return (res.Attributes ?? {}) as QueueAttributes
}
}
function toResource(name: string, url: string, attributes: QueueAttributes): CloudResource {
return {
id: name,
name,
cloud: 'aws',
service: 'messaging',
type: attributes.FifoQueue === 'true' ? 'fifo-queue' : 'queue',
region: null,
createdAt: epochSecondsToIso(attributes.CreatedTimestamp),
metadata: {
provider: 'aws',
messagingService: 'sqs',
queueUrl: url,
arn: attributes.QueueArn,
approximateMessages: numberValue(attributes.ApproximateNumberOfMessages),
messagesInFlight: numberValue(attributes.ApproximateNumberOfMessagesNotVisible),
messagesDelayed: numberValue(attributes.ApproximateNumberOfMessagesDelayed),
visibilityTimeout: numberValue(attributes.VisibilityTimeout),
messageRetentionPeriod: numberValue(attributes.MessageRetentionPeriod),
maximumMessageSize: numberValue(attributes.MaximumMessageSize),
delaySeconds: numberValue(attributes.DelaySeconds),
lastModified: epochSecondsToIso(attributes.LastModifiedTimestamp),
},
}
}
/** SQS reports timestamps as epoch seconds; the contract uses ISO strings. */
function epochSecondsToIso(value: string | undefined): string | null {
if (!value) return null
const seconds = Number(value)
return Number.isFinite(seconds) ? new Date(seconds * 1000).toISOString() : null
}
function numberValue(value: string | undefined): number | null {
if (value === undefined) return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
function queueNameFromUrl(url: string): string {
return url.split('/').filter(Boolean).pop() ?? url
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
/**
* Validate an optional numeric queue attribute against SQS's documented range.
*
* Returns '' when the caller left it blank — these are optional, and an empty
* field must not become an attribute.
*/
function numericAttribute(value: unknown, field: string, min: number, max: number): string {
const raw = stringValue(value)
if (!raw) return ''
if (!/^\d+$/.test(raw)) {
throw new ValidationError(`${field} must be a whole number of seconds between ${min} and ${max}.`)
}
const parsed = Number(raw)
if (parsed < min || parsed > max) {
throw new ValidationError(`${field} must be between ${min} and ${max} seconds.`)
}
return raw
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
}
function isFifoName(value: string): boolean {
return value.endsWith('.fifo')
}
function isValidQueueName(value: string): boolean {
// FIFO queues must end in .fifo, which is the one place a dot is allowed.
return /^[A-Za-z0-9_-]{1,80}$/.test(value) || /^[A-Za-z0-9_-]{1,75}\.fifo$/.test(value)
}
function isQueueMissing(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false
const name = (error as {name?: string}).name
return name === 'QueueDoesNotExist' || name === 'AWS.SimpleQueueService.NonExistentQueue'
}
@@ -0,0 +1,224 @@
import {describe, expect, test} from 'bun:test'
import {
CreateStateMachineCommand,
DeleteStateMachineCommand,
DescribeStateMachineCommand,
ListStateMachinesCommand,
type SFNClient,
} from '@aws-sdk/client-sfn'
import {AwsStepFunctionsAdapter} from './AwsStepFunctionsAdapter'
import {ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
function stubSfn(handler: (command: object) => SendResult | Promise<SendResult>) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
return handler(command)
},
} as unknown as SFNClient
return {client, sent}
}
const ARN = 'arn:aws:states:us-east-1:000000000000:stateMachine:orders'
const DEFINITION = `{
"StartAt": "Done",
"States": {
"Done": {"Type": "Succeed"}
}
}`
const ROLE = 'arn:aws:iam::000000000000:role/sfn'
const summary = {
stateMachineArn: ARN,
name: 'orders',
type: 'STANDARD',
creationDate: new Date('2026-07-28T10:00:00.000Z'),
}
const described = {
...summary,
definition: DEFINITION,
roleArn: ROLE,
status: 'ACTIVE',
}
function runtimeStub() {
return stubSfn((command) => {
if (command instanceof ListStateMachinesCommand) return {stateMachines: [summary]}
if (command instanceof DescribeStateMachineCommand) return described
if (command instanceof CreateStateMachineCommand) return {stateMachineArn: ARN, creationDate: new Date()}
return {}
})
}
const validValues = {name: 'orders', roleArn: ROLE, definition: DEFINITION}
describe('AwsStepFunctionsAdapter', () => {
test('identifies itself as the AWS workflows adapter', () => {
const adapter = new AwsStepFunctionsAdapter(runtimeStub().client)
expect(adapter.cloud).toBe('aws')
expect(adapter.service).toBe('workflows')
expect(adapter.schema().displayName).toBe('AWS Step Functions')
})
test('lists state machines from the summary alone', async () => {
const {client, sent} = runtimeStub()
const [resource] = await new AwsStepFunctionsAdapter(client).list()
expect(sent[0]).toBeInstanceOf(ListStateMachinesCommand)
expect(resource).toMatchObject({
// The ARN is the id: DescribeStateMachine and DeleteStateMachine both
// take one, and a name alone cannot address the machine.
id: ARN,
name: 'orders',
cloud: 'aws',
service: 'workflows',
type: 'state-machine',
region: 'us-east-1',
createdAt: '2026-07-28T10:00:00.000Z',
})
expect(resource?.metadata.machineType).toBe('STANDARD')
})
test('does not describe each row while listing', async () => {
// ListStateMachines omits status and definition, and real Step Functions
// does too. Fetching them per row would make the table an N+1.
const {client, sent} = runtimeStub()
await new AwsStepFunctionsAdapter(client).list()
expect(sent.some((c) => c instanceof DescribeStateMachineCommand)).toBe(false)
})
test('follows the pagination token', async () => {
let call = 0
const {client, sent} = stubSfn((command) => {
if (!(command instanceof ListStateMachinesCommand)) return {}
call += 1
if (call === 1) return {stateMachines: [summary], nextToken: 'page-2'}
return {stateMachines: [{...summary, name: 'refunds', stateMachineArn: `${ARN}-2`}]}
})
const resources = await new AwsStepFunctionsAdapter(client).list()
expect(resources.map((r) => r.name)).toEqual(['orders', 'refunds'])
expect((sent[1] as ListStateMachinesCommand).input.nextToken).toBe('page-2')
})
test('filters by name and ARN', async () => {
const adapter = new AwsStepFunctionsAdapter(runtimeStub().client)
await expect(adapter.list({search: 'ord'})).resolves.toHaveLength(1)
await expect(adapter.list({search: 'stateMachine:orders'})).resolves.toHaveLength(1)
await expect(adapter.list({search: 'nope'})).resolves.toHaveLength(0)
})
test('inspects a state machine and surfaces the definition and status', async () => {
const {client, sent} = runtimeStub()
const resource = await new AwsStepFunctionsAdapter(client).get(ARN)
expect((sent[0] as DescribeStateMachineCommand).input.stateMachineArn).toBe(ARN)
// status is the canonical CloudResource field, not a metadata entry.
expect(resource?.status).toBe('ACTIVE')
expect(resource?.metadata).toMatchObject({definition: DEFINITION, roleArn: ROLE})
})
test('leaves status null in a listing, where the runtime does not return it', async () => {
const {client} = runtimeStub()
const [resource] = await new AwsStepFunctionsAdapter(client).list()
expect(resource?.status).toBeNull()
})
test('returns null on StateMachineDoesNotExist even though the runtime answers 400', async () => {
// Step Functions reports a missing machine as StateMachineDoesNotExist with
// HTTP 400, not 404, so matching on the status would rethrow and show a
// runtime error where an empty result belongs.
const {client} = stubSfn(() => {
throw Object.assign(new Error('State machine does not exist'), {
name: 'StateMachineDoesNotExist',
$metadata: {httpStatusCode: 400},
})
})
await expect(new AwsStepFunctionsAdapter(client).get(ARN)).resolves.toBeNull()
})
test('rethrows a failure that is not a missing machine', async () => {
const {client} = stubSfn(() => {
throw Object.assign(new Error('AccessDenied'), {
name: 'AccessDeniedException',
$metadata: {httpStatusCode: 403},
})
})
await expect(new AwsStepFunctionsAdapter(client).get(ARN)).rejects.toThrow('AccessDenied')
})
test('creates a state machine and reads it back', async () => {
const {client, sent} = runtimeStub()
const resource = await new AwsStepFunctionsAdapter(client).create({values: validValues})
const command = sent[0] as CreateStateMachineCommand
expect(command).toBeInstanceOf(CreateStateMachineCommand)
expect(command.input.name).toBe('orders')
expect(command.input.definition).toBe(DEFINITION)
expect(command.input.definition).toContain('\n')
expect(command.input.roleArn).toBe(ROLE)
// create returns only an ARN, so the machine is described to build the
// resource rather than synthesising one from the input.
expect(sent[1]).toBeInstanceOf(DescribeStateMachineCommand)
expect(resource.id).toBe(ARN)
})
test('requires the fields the schema marks required', async () => {
const adapter = new AwsStepFunctionsAdapter(runtimeStub().client)
const cases: Array<[Record<string, unknown>, string]> = [
[{}, 'name is required'],
[{name: 'orders'}, 'roleArn is required'],
[{name: 'orders', roleArn: ROLE}, 'definition is required'],
]
for (const [values, message] of cases) {
await expect(adapter.create({values})).rejects.toThrow(new ValidationError(message))
}
})
test('rejects a definition that is not JSON', async () => {
const adapter = new AwsStepFunctionsAdapter(runtimeStub().client)
await expect(adapter.create({values: {...validValues, definition: 'not json'}})).rejects.toThrow(
ValidationError,
)
})
test('rejects a definition missing StartAt or States', async () => {
// Valid JSON is not a valid state machine. The runtime rejects it, but with
// a parser message; naming the missing field is more actionable.
const adapter = new AwsStepFunctionsAdapter(runtimeStub().client)
await expect(adapter.create({values: {...validValues, definition: '{"States":{}}'}})).rejects.toThrow(
new ValidationError('definition must contain StartAt'),
)
await expect(adapter.create({values: {...validValues, definition: '{"StartAt":"Done"}'}})).rejects.toThrow(
new ValidationError('definition must contain States'),
)
})
test('rejects a name or role ARN the runtime would refuse', async () => {
const adapter = new AwsStepFunctionsAdapter(runtimeStub().client)
await expect(adapter.create({values: {...validValues, name: 'has spaces'}})).rejects.toThrow(ValidationError)
await expect(adapter.create({values: {...validValues, name: 'a'.repeat(81)}})).rejects.toThrow(ValidationError)
await expect(adapter.create({values: {...validValues, roleArn: 'not-an-arn'}})).rejects.toThrow(ValidationError)
})
test('deletes a state machine by ARN', async () => {
const {client, sent} = runtimeStub()
await new AwsStepFunctionsAdapter(client).delete(ARN)
expect((sent[0] as DeleteStateMachineCommand).input.stateMachineArn).toBe(ARN)
})
})
@@ -0,0 +1,186 @@
import {
CreateStateMachineCommand,
DeleteStateMachineCommand,
DescribeStateMachineCommand,
ListStateMachinesCommand,
type SFNClient,
type StateMachineListItem,
} from '@aws-sdk/client-sfn'
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import {STATE_MACHINE_NAME_PATTERN, awsWorkflowsSchema} from '../cloud-spi/workflowsSchema'
import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
/**
* Talks to Step Functions — verified against Floci core 1.5.33.
*
* Notes from probing the runtime:
* - A missing state machine is `StateMachineDoesNotExist` with HTTP **400**, not
* 404, so `get` matches on the error name. `awsErrors.ts` now maps that name so
* the route reports 404 rather than a validation failure.
* - `ListStateMachines` returns only the ARN, name, type and creation date — real
* Step Functions does the same. Status and definition live on
* `DescribeStateMachine`, so they are fetched on inspect only; doing it per row
* would make the table an N+1.
* - `CreateStateMachine` returns only an ARN and a date, so create describes the
* machine afterwards rather than synthesising a resource from its own input.
* - The runtime does validate the definition (`InvalidDefinition`), but its
* message is a JSON parser error, so the obvious problems are caught here with
* something more actionable.
*/
type DescribedMachine = {
stateMachineArn?: string
name?: string
type?: string
status?: string
definition?: string
roleArn?: string
creationDate?: Date
label?: string
revisionId?: string
}
export class AwsStepFunctionsAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'workflows' as const
constructor(private readonly sfn: SFNClient) {}
schema(): ServiceSchema {
return awsWorkflowsSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const machines: StateMachineListItem[] = []
let nextToken: string | undefined
do {
const res = await this.sfn.send(new ListStateMachinesCommand(nextToken ? {nextToken} : {}))
machines.push(...(res.stateMachines ?? []))
nextToken = res.nextToken
} while (nextToken)
return filterBySearch(machines.map(toResource), query.search)
}
async get(id: string): Promise<CloudResource | null> {
try {
const res = await this.sfn.send(new DescribeStateMachineCommand({stateMachineArn: id}))
return toResource(res as DescribedMachine)
} catch (error) {
if (isMissing(error)) return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const name = requiredString(input.values.name, 'name')
if (!new RegExp(STATE_MACHINE_NAME_PATTERN).test(name)) {
throw new ValidationError(
'name must be up to 80 characters of letters, digits, hyphens and underscores',
)
}
const roleArn = requiredString(input.values.roleArn, 'roleArn')
if (!/^arn:aws[a-z-]*:iam::\d{12}:role\/.+$/.test(roleArn)) {
throw new ValidationError('roleArn must be an IAM role ARN, e.g. arn:aws:iam::000000000000:role/my-role')
}
const definition = requiredDefinition(input.values.definition)
const res = await this.sfn.send(new CreateStateMachineCommand({name, definition, roleArn}))
const arn = res.stateMachineArn
if (!arn) throw new RuntimeError(`Step Functions did not return an ARN for ${name}`)
// create returns only the ARN, so read the machine back for the real shape.
const created = await this.get(arn)
if (!created) throw new RuntimeError(`State machine ${name} was created but could not be read back`)
return created
}
async delete(id: string): Promise<void> {
await this.sfn.send(new DeleteStateMachineCommand({stateMachineArn: id}))
}
}
function toResource(machine: StateMachineListItem | DescribedMachine): CloudResource {
const described = machine as DescribedMachine
const arn = machine.stateMachineArn ?? ''
return {
// The ARN is the identity: Describe and Delete both take one, and a name
// alone cannot address the machine. It survives the generic route because
// HttpClient encodes path params.
id: arn,
name: machine.name ?? '',
cloud: 'aws',
service: 'workflows',
type: 'state-machine',
region: regionFromArn(arn),
createdAt: machine.creationDate ? machine.creationDate.toISOString() : null,
// Only DescribeStateMachine returns status, so this is null in a listing.
status: described.status ?? null,
metadata: {
arn,
/** `type` is taken by CloudResource, so the machine's own type is here. */
machineType: machine.type,
definition: described.definition,
roleArn: described.roleArn,
label: described.label,
revisionId: described.revisionId,
},
}
}
/** `arn:aws:states:us-east-1:000000000000:stateMachine:name` — region is field 4. */
function regionFromArn(arn: string): string | null {
return arn.split(':')[3] || null
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter(
(resource) =>
resource.name.toLowerCase().includes(normalized) || resource.id.toLowerCase().includes(normalized),
)
}
/**
* Valid JSON is not a valid state machine. The runtime rejects a bad definition
* with a JSON parser message; naming the missing field is more actionable.
*/
function requiredDefinition(value: unknown): string {
const raw = requiredString(value, 'definition')
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
throw new ValidationError('definition must be valid JSON')
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new ValidationError('definition must be a JSON object')
}
for (const field of ['StartAt', 'States'] as const) {
if (!(field in parsed)) throw new ValidationError(`definition must contain ${field}`)
}
return raw
}
function isMissing(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false
return (error as {name?: string}).name === 'StateMachineDoesNotExist'
}
function requiredString(value: unknown, field: string): string {
if (value === undefined || value === null || value === '') throw new ValidationError(`${field} is required`)
if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`)
const trimmed = value.trim()
if (!trimmed) throw new ValidationError(`${field} is required`)
return trimmed
}
@@ -0,0 +1,103 @@
import {describe, expect, test} from 'bun:test'
import {
GetBucketTaggingCommand,
ListBucketsCommand,
type S3Client,
} from '@aws-sdk/client-s3'
import {AwsStorageAdapter} from './AwsStorageAdapter'
function fakeS3(overrides: Partial<{
buckets: Array<{Name: string; CreationDate?: Date}>
tagSet: Array<{Key: string; Value: string}>
taggingError: Error
onGetBucketTagging: (bucketName: string) => void
}> = {}): S3Client {
return {
send: async (command: unknown) => {
if (command instanceof ListBucketsCommand) {
return {
Buckets: overrides.buckets ?? [
{Name: 'my-tf-test-bucket', CreationDate: new Date('2026-01-01')},
],
}
}
if (command instanceof GetBucketTaggingCommand) {
overrides.onGetBucketTagging?.((command as {input: {Bucket: string}}).input.Bucket)
if (overrides.taggingError) throw overrides.taggingError
return {TagSet: overrides.tagSet ?? [{Key: 'Environment', Value: 'dev'}]}
}
throw new Error(`Unexpected command: ${command?.constructor?.name}`)
},
} as unknown as S3Client
}
describe('AwsStorageAdapter', () => {
test('list includes bucket tags in metadata', async () => {
const adapter = new AwsStorageAdapter(fakeS3())
const result = await adapter.list()
expect(result).toHaveLength(1)
expect(result[0].metadata.tags).toEqual([{key: 'Environment', value: 'dev'}])
expect(result[0].metadata.tagsUnavailable).toBe(false)
})
test('list returns empty tags for untagged bucket instead of throwing', async () => {
const error = new Error('The TagSet does not exist')
error.name = 'NoSuchTagSet'
const adapter = new AwsStorageAdapter(fakeS3({taggingError: error}))
const result = await adapter.list()
expect(result[0].metadata.tags).toEqual([])
expect(result[0].metadata.tagsUnavailable).toBe(false)
})
test('list does not fail entirely when tag fetch is denied for a bucket', async () => {
const error = new Error('Access Denied')
error.name = 'AccessDenied'
const adapter = new AwsStorageAdapter(fakeS3({taggingError: error}))
const result = await adapter.list()
expect(result).toHaveLength(1)
expect(result[0].metadata.tags).toEqual([])
})
test('list marks tags unavailable (not empty) when tag fetch is denied', async () => {
const error = new Error('Access Denied')
error.name = 'AccessDenied'
const adapter = new AwsStorageAdapter(fakeS3({taggingError: error}))
const result = await adapter.list()
expect(result[0].metadata.tagsUnavailable).toBe(true)
})
test('list marks tags unavailable on transport/runtime failures too', async () => {
const error = new Error('socket hang up')
error.name = 'TimeoutError'
const adapter = new AwsStorageAdapter(fakeS3({taggingError: error}))
const result = await adapter.list()
expect(result[0].metadata.tags).toEqual([])
expect(result[0].metadata.tagsUnavailable).toBe(true)
})
test('search filters buckets before fetching tags, not after', async () => {
const requestedBuckets: string[] = []
const adapter = new AwsStorageAdapter(fakeS3({
buckets: [
{Name: 'prod-logs'},
{Name: 'dev-logs'},
{Name: 'prod-assets'},
],
onGetBucketTagging: (bucketName) => requestedBuckets.push(bucketName),
}))
const result = await adapter.list({search: 'prod'})
expect(result.map((r) => r.name).sort()).toEqual(['prod-assets', 'prod-logs'])
expect(requestedBuckets.sort()).toEqual(['prod-assets', 'prod-logs'])
})
})
@@ -0,0 +1,200 @@
import {ValidationError} from '../cloud-spi/errors'
import {
type Bucket,
CopyObjectCommand,
CreateBucketCommand,
DeleteBucketCommand,
DeleteObjectCommand,
GetBucketTaggingCommand,
GetObjectCommand,
ListBucketsCommand,
ListObjectsV2Command,
PutObjectCommand,
type S3Client,
} from '@aws-sdk/client-s3'
import {s3 as defaultS3} from '../aws'
import {awsStorageSchema} from '../cloud-spi/storageSchema'
import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
ResourceQuery,
ServiceSchema,
StorageObjectDownload,
StorageObjectList,
} from '../cloud-spi/types'
export class AwsStorageAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'storage' as const
constructor(private readonly s3: S3Client = defaultS3) {}
schema(): ServiceSchema {
return awsStorageSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const res = await this.s3.send(new ListBucketsCommand({}))
const buckets = filterBucketsBySearch(res.Buckets ?? [], query.search)
return Promise.all(buckets.map(async (bucket): Promise<CloudResource> => {
const {tags, tagsUnavailable} = await this.bucketTags(bucket.Name ?? '')
return {
id: bucket.Name ?? '',
name: bucket.Name ?? '',
cloud: 'aws',
service: 'storage',
type: 'bucket',
region: null,
createdAt: bucket.CreationDate?.toISOString() ?? null,
metadata: {
provider: 'aws',
storageService: 's3',
tags,
tagsUnavailable,
},
}
}))
}
private async bucketTags(bucketName: string): Promise<{tags: Array<{key: string; value: string}>; tagsUnavailable: boolean}> {
if (!bucketName) return {tags: [], tagsUnavailable: false}
try {
const res = await this.s3.send(new GetBucketTaggingCommand({Bucket: bucketName}))
return {
tags: (res.TagSet ?? []).map((tag) => ({key: tag.Key ?? '', value: tag.Value ?? ''})),
tagsUnavailable: false,
}
} catch (err) {
// NoSuchTagSet is a real, valid answer: the bucket has no tags. Anything else
// (AccessDenied, throttling, transport failures) means we don't actually know
// whether the bucket has tags, so callers must not treat it as "no tags".
if (err instanceof Error && err.name === 'NoSuchTagSet') {
return {tags: [], tagsUnavailable: false}
}
return {tags: [], tagsUnavailable: true}
}
}
async get(id: string): Promise<CloudResource | null> {
const resources = await this.list()
return resources.find((resource) => resource.id === id) ?? null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const bucketName = stringValue(input.values.bucketName)
if (!bucketName) throw new ValidationError('bucketName is required')
if (!isValidS3BucketName(bucketName)) {
throw new ValidationError('Use a valid S3 bucket name: 3-63 lowercase characters, numbers, dots, or hyphens.')
}
await this.s3.send(new CreateBucketCommand({Bucket: bucketName}))
return {
id: bucketName,
name: bucketName,
cloud: 'aws',
service: 'storage',
type: 'bucket',
region: stringValue(input.values.region) || null,
createdAt: null,
metadata: {
provider: 'aws',
storageService: 's3',
},
}
}
async delete(id: string): Promise<void> {
await this.s3.send(new DeleteBucketCommand({Bucket: id}))
}
async listObjects(resourceId: string, prefix = ''): Promise<StorageObjectList> {
const res = await this.s3.send(new ListObjectsV2Command({
Bucket: resourceId,
Prefix: prefix || undefined,
Delimiter: '/',
}))
return {
prefix,
objects: [
...(res.CommonPrefixes ?? []).map((item) => {
const key = item.Prefix ?? ''
return {
key,
name: objectName(key, prefix),
type: 'folder' as const,
size: null,
lastModified: null,
metadata: {
provider: 'aws',
storageService: 's3',
prefix: key,
},
}
}),
...(res.Contents ?? [])
.filter((item) => item.Key && item.Key !== prefix)
.map((item) => ({
key: item.Key ?? '',
name: objectName(item.Key ?? '', prefix),
type: 'object' as const,
size: item.Size ?? null,
lastModified: item.LastModified?.toISOString() ?? null,
metadata: {
provider: 'aws',
storageService: 's3',
etag: item.ETag?.replace(/"/g, ''),
storageClass: item.StorageClass,
},
})),
],
}
}
async putObject(resourceId: string, key: string, body: Uint8Array, contentType: string): Promise<void> {
await this.s3.send(new PutObjectCommand({Bucket: resourceId, Key: key, Body: body, ContentType: contentType}))
}
async getObject(resourceId: string, key: string): Promise<StorageObjectDownload> {
const res = await this.s3.send(new GetObjectCommand({Bucket: resourceId, Key: key}))
return {
body: res.Body as BodyInit,
contentType: res.ContentType ?? 'application/octet-stream',
contentLength: res.ContentLength ?? null,
}
}
async deleteObject(resourceId: string, key: string): Promise<void> {
await this.s3.send(new DeleteObjectCommand({Bucket: resourceId, Key: key}))
}
async copyObject(srcResourceId: string, srcKey: string, destKey: string, destResourceId?: string): Promise<void> {
const destBucket = destResourceId ?? srcResourceId
await this.s3.send(new CopyObjectCommand({
Bucket: destBucket,
Key: destKey,
CopySource: `${srcResourceId}/${srcKey}`,
}))
}
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function filterBucketsBySearch(buckets: Bucket[], search?: string): Bucket[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return buckets
return buckets.filter((bucket) => (bucket.Name ?? '').toLowerCase().includes(normalized))
}
function objectName(key: string, prefix: string): string {
const relative = key.startsWith(prefix) ? key.slice(prefix.length) : key
return relative.replace(/\/$/, '') || key
}
function isValidS3BucketName(value: string): boolean {
return /^(?!\d+\.\d+\.\d+\.\d+$)(?!.*\.\.)(?!.*\.-)(?!.*-\.)(?!.*--x-s3$)(?!.*-s3alias$)[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(value)
}
@@ -0,0 +1,129 @@
import {describe, expect, test} from 'bun:test'
import type {CloudErrorCode, CloudErrorStatus} from '../cloud-spi/errors'
import {isAwsSdkError, mapAwsSdkError} from './awsErrors'
/** Build an error shaped like an AWS SDK v3 failure. */
function sdkError(name: string, extra: Record<string, unknown> = {}): Error {
const err = new Error(`${name} from the runtime`)
err.name = name
return Object.assign(err, {$fault: 'client', $metadata: {httpStatusCode: 500}, ...extra})
}
describe('isAwsSdkError', () => {
test('recognises the SDK envelope', () => {
expect(isAwsSdkError(sdkError('NoSuchBucket'))).toBe(true)
expect(isAwsSdkError(Object.assign(new Error('x'), {$retryable: {}}))).toBe(true)
})
test('rejects anything else so the generic mapper can handle it', () => {
expect(isAwsSdkError(new Error('plain'))).toBe(false)
expect(isAwsSdkError(null)).toBe(false)
expect(isAwsSdkError('string')).toBe(false)
})
})
describe('mapAwsSdkError', () => {
test('returns null for a non-SDK error', () => {
expect(mapAwsSdkError(new Error('plain'))).toBeNull()
})
const cases: Array<[string, CloudErrorStatus, CloudErrorCode]> = [
// throttling — must win over everything, including the 500 metadata status
['ThrottlingException', 429, 'rate_limited'],
['ProvisionedThroughputExceededException', 429, 'rate_limited'],
['SlowDown', 429, 'rate_limited'],
// conflicts — previously all 502
['BucketAlreadyExists', 409, 'resource_conflict'],
['BucketAlreadyOwnedByYou', 409, 'resource_conflict'],
['EntityAlreadyExists', 409, 'resource_conflict'],
['ResourceExistsException', 409, 'resource_conflict'],
['StateMachineAlreadyExists', 409, 'resource_conflict'],
['ResourceInUseException', 409, 'resource_conflict'],
['QueueNameExists', 409, 'resource_conflict'],
// access
['AccessDenied', 403, 'access_denied'],
['UnauthorizedOperation', 403, 'access_denied'],
['SignatureDoesNotMatch', 403, 'access_denied'],
// validation
['ValidationException', 400, 'invalid_request'],
['InvalidParameterValue', 400, 'invalid_request'],
['MalformedPolicyDocument', 400, 'invalid_request'],
// A bad parameter set, not an existing-resource clash.
['InvalidParameterCombination', 400, 'invalid_request'],
// not found
['NoSuchBucket', 404, 'resource_not_found'],
['NoSuchKey', 404, 'resource_not_found'],
['ResourceNotFoundException', 404, 'resource_not_found'],
['QueueDoesNotExist', 404, 'resource_not_found'],
// EC2 uses a dotted suffix convention
['InvalidVpcID.NotFound', 404, 'resource_not_found'],
['InvalidInstanceID.NotFound', 404, 'resource_not_found'],
// runtime gaps
['NotImplemented', 501, 'operation_not_implemented'],
['UnsupportedOperation', 501, 'operation_not_implemented'],
['ServiceUnavailable', 503, 'runtime_unavailable'],
]
for (const [name, status, code] of cases) {
test(`${name} maps to ${status}`, () => {
const mapped = mapAwsSdkError(sdkError(name))
expect(mapped?.status).toBe(status)
expect(mapped?.code).toBe(code)
expect(mapped?.message).toContain(name)
})
}
test('matches the AlreadyExists suffix convention for unlisted names', () => {
expect(mapAwsSdkError(sdkError('WidgetAlreadyExistsException'))?.status).toBe(409)
})
test('honours the $retryable throttling flag even for an unknown name', () => {
const mapped = mapAwsSdkError(sdkError('SomeNewException', {$retryable: {throttling: true}}))
expect(mapped?.status).toBe(429)
})
test('falls back to the metadata status only when the name is unknown', () => {
expect(mapAwsSdkError(sdkError('MysteryFailure', {$metadata: {httpStatusCode: 404}}))?.status).toBe(404)
expect(mapAwsSdkError(sdkError('MysteryFailure', {$metadata: {httpStatusCode: 409}}))?.status).toBe(409)
})
test('prefers the error name over a misleading metadata status', () => {
// Local emulators frequently answer 500 for what is really a conflict.
const mapped = mapAwsSdkError(sdkError('BucketAlreadyExists', {$metadata: {httpStatusCode: 500}}))
expect(mapped?.status).toBe(409)
})
test('defaults to 502 when neither name nor status is informative', () => {
expect(mapAwsSdkError(sdkError('MysteryFailure'))?.status).toBe(502)
})
})
/**
* CloudWatch Logs returns both of these with HTTP 400, verified against the
* runtime on 2026-07-28:
* DescribeLogStreams on a missing group -> 400 ResourceNotFoundException
* duplicate CreateLogGroup -> 400 ResourceAlreadyExistsException
*
* Both already map correctly, because the name tables run ahead of the
* `$metadata.httpStatusCode` floor. Nothing pinned that ordering against a
* 400-carrying not-found, and an unpinned ordering is how #168 shipped a green
* suite over a 400-instead-of-404 bug.
*/
describe('logs errors carry HTTP 400 and must not fall through to it', () => {
test('a not-found keeps its 404 despite the 400 status', () => {
const mapped = mapAwsSdkError(
sdkError('ResourceNotFoundException', {$metadata: {httpStatusCode: 400}}),
)
expect(mapped?.status).toBe(404)
expect(mapped?.code).toBe('resource_not_found')
})
test('an already-exists keeps its 409 despite the 400 status', () => {
const mapped = mapAwsSdkError(
sdkError('ResourceAlreadyExistsException', {$metadata: {httpStatusCode: 400}}),
)
expect(mapped?.status).toBe(409)
})
})
+183
View File
@@ -0,0 +1,183 @@
/**
* Maps AWS SDK v3 errors onto cloud-SPI errors.
*
* Vendor knowledge lives here rather than in `cloud-spi/` so the generic mapper
* stays provider-neutral. Match order matters: throttling first, then the name
* tables, and `$metadata.httpStatusCode` only as a last resort — local emulators
* are less consistent about status codes than they are about error names.
*/
import {
AccessDeniedError,
type CloudError,
ConflictError,
NotFoundError,
NotImplementedByRuntimeError,
RateLimitedError,
RuntimeError,
RuntimeUnavailableError,
ValidationError,
isUnreachableCause,
} from '../cloud-spi/errors'
interface AwsSdkErrorShape {
name?: string
message?: string
$fault?: 'client' | 'server'
$retryable?: {throttling?: boolean}
$metadata?: {httpStatusCode?: number}
}
const THROTTLING_NAMES = new Set([
'ThrottlingException',
'Throttling',
'ThrottledException',
'TooManyRequestsException',
'RequestLimitExceeded',
'ProvisionedThroughputExceededException',
'RequestThrottled',
'RequestThrottledException',
'SlowDown',
'LimitExceededException',
])
const CONFLICT_NAMES = new Set([
'BucketAlreadyExists',
'BucketAlreadyOwnedByYou',
'EntityAlreadyExists',
'EntityAlreadyExistsException',
'ResourceExistsException',
'ResourceInUseException',
'ResourceConflictException',
'ConditionalCheckFailedException',
'QueueNameExists',
'QueueDeletedRecently',
'InvalidChangeBatch',
'DBInstanceAlreadyExists',
'DBInstanceAlreadyExistsFault',
'ConcurrentModificationException',
'IncorrectState',
'DeleteConflictException',
])
const ACCESS_DENIED_NAMES = new Set([
'AccessDenied',
'AccessDeniedException',
'UnauthorizedOperation',
'AuthFailure',
'InvalidClientTokenId',
'SignatureDoesNotMatch',
'Forbidden',
'MissingAuthenticationToken',
'InvalidAccessKeyId',
])
const VALIDATION_NAMES = new Set([
'ValidationException',
'ValidationError',
'InvalidParameterValue',
'InvalidParameterValueException',
'InvalidParameterCombination',
'InvalidRequestException',
'InvalidInput',
'InvalidInputException',
'MissingParameter',
'MissingRequiredParameter',
'SerializationException',
'MalformedPolicyDocument',
'MalformedPolicyDocumentException',
'InvalidArgsException',
'InvalidBucketName',
'InvalidArgumentException',
'InvalidParameterException',
])
const NOT_FOUND_NAMES = new Set([
'NoSuchBucket',
'NoSuchKey',
'NoSuchEntity',
'NoSuchEntityException',
'NotFound',
'NotFoundException',
'ResourceNotFoundException',
'ResourceNotFoundFault',
'DBInstanceNotFound',
'DBInstanceNotFoundFault',
'ParameterNotFound',
'QueueDoesNotExist',
'StateMachineDoesNotExist',
'ExecutionDoesNotExist',
'LoadBalancerNotFound',
'LoadBalancerNotFoundException',
'TargetGroupNotFound',
'TargetGroupNotFoundException',
'SecretNotFoundException',
'NoSuchLogGroup',
'NoSuchResourceException',
'InvalidInstanceID.NotFound',
'InvalidVpcID.NotFound',
'InvalidSubnetID.NotFound',
'InvalidGroupId.NotFound',
'InvalidAMIID.NotFound',
])
const NOT_IMPLEMENTED_NAMES = new Set([
'NotImplemented',
'UnsupportedOperation',
'UnsupportedOperationException',
])
const UNAVAILABLE_NAMES = new Set([
'NetworkingError',
'TimeoutError',
'ServiceUnavailable',
'ServiceUnavailableException',
'RequestTimeout',
'InternalFailure',
'InternalError',
])
/** True when the value carries the AWS SDK v3 error envelope. */
export function isAwsSdkError(err: unknown): boolean {
if (!err || typeof err !== 'object') return false
return '$metadata' in err || '$fault' in err || '$retryable' in err
}
/**
* Translate an AWS SDK error, or return `null` when it is not one so the caller
* can fall through to the generic mapping.
*/
export function mapAwsSdkError(err: unknown): CloudError | null {
if (!isAwsSdkError(err)) return null
const sdkError = err as AwsSdkErrorShape
const name = sdkError.name ?? ''
const message = sdkError.message ?? name ?? 'AWS request failed'
const options = {cause: err}
if (sdkError.$retryable?.throttling === true || THROTTLING_NAMES.has(name)) {
return new RateLimitedError(message, options)
}
if (CONFLICT_NAMES.has(name) || /AlreadyExists(Exception|Fault)?$/.test(name)) {
return new ConflictError(message, options)
}
if (ACCESS_DENIED_NAMES.has(name)) return new AccessDeniedError(message, options)
if (VALIDATION_NAMES.has(name)) return new ValidationError(message, options)
if (NOT_FOUND_NAMES.has(name) || /\.NotFound$/.test(name)) return new NotFoundError(message, options)
if (NOT_IMPLEMENTED_NAMES.has(name)) return new NotImplementedByRuntimeError(message, options)
if (UNAVAILABLE_NAMES.has(name) || isUnreachableCause(err)) {
return new RuntimeUnavailableError(message, options)
}
// Emulator status codes are less reliable than names, so this is the floor.
const status = sdkError.$metadata?.httpStatusCode
if (status === 400) return new ValidationError(message, options)
if (status === 401 || status === 403) return new AccessDeniedError(message, options)
if (status === 404) return new NotFoundError(message, options)
if (status === 409) return new ConflictError(message, options)
if (status === 429) return new RateLimitedError(message, options)
if (status === 501) return new NotImplementedByRuntimeError(message, options)
if (status === 503 || status === 504) return new RuntimeUnavailableError(message, options)
return new RuntimeError(message, options)
}
@@ -0,0 +1,118 @@
import {describe, expect, test} from 'bun:test'
import {execFile} from 'node:child_process'
import {rm, writeFile} from 'node:fs/promises'
import {tmpdir} from 'node:os'
import {createZipArchive, crc32, handlerFileName} from './zipArchive'
describe('crc32', () => {
// Reference values from the standard CRC-32 (IEEE 802.3) test vectors.
test('matches known checksums', () => {
expect(crc32(new TextEncoder().encode(''))).toBe(0x00000000)
expect(crc32(new TextEncoder().encode('a'))).toBe(0xe8b7be43)
expect(crc32(new TextEncoder().encode('abc'))).toBe(0x352441c2)
expect(crc32(new TextEncoder().encode('123456789'))).toBe(0xcbf43926)
})
})
describe('createZipArchive', () => {
test('writes the ZIP magic number and end-of-central-directory record', () => {
const archive = createZipArchive([{name: 'index.js', content: 'module.exports = 1'}])
const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength)
expect(view.getUint32(0, true)).toBe(0x04034b50)
expect(view.getUint32(archive.length - 22, true)).toBe(0x06054b50)
// One entry, recorded in both the disk and total counts.
expect(view.getUint16(archive.length - 22 + 8, true)).toBe(1)
expect(view.getUint16(archive.length - 22 + 10, true)).toBe(1)
})
test('records the entry name and an accurate checksum', () => {
const content = 'exports.handler = async () => ({statusCode: 200})'
const archive = createZipArchive([{name: 'index.js', content}])
const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength)
const encoded = new TextEncoder().encode(content)
expect(view.getUint32(14, true)).toBe(crc32(encoded))
expect(view.getUint32(18, true)).toBe(encoded.length)
expect(view.getUint32(22, true)).toBe(encoded.length)
expect(new TextDecoder().decode(archive.slice(30, 38))).toBe('index.js')
})
test('is readable by an independent ZIP implementation', async () => {
// Validates against a real reader rather than trusting our writer to agree
// with itself — a self-consistent but malformed archive is exactly the bug
// this file exists to prevent.
const archive = createZipArchive([
{name: 'index.js', content: 'exports.handler = 1'},
{name: 'lib/util.js', content: 'module.exports = {}'},
])
const path = `${tmpdir()}/floci-zip-${process.pid}.zip`
await writeFile(path, archive)
try {
const listing = await new Promise<string>((resolve, reject) => {
execFile('unzip', ['-Z1', path], (err, stdout) => (err ? reject(err) : resolve(stdout)))
}).catch(() => null)
if (listing === null) {
// No unzip on this machine; assert the structure we can check.
const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength)
expect(view.getUint16(archive.length - 22 + 8, true)).toBe(2)
return
}
expect(listing.split('\n').filter(Boolean).sort()).toEqual(['index.js', 'lib/util.js'])
const extracted = await new Promise<string>((resolve, reject) => {
execFile('unzip', ['-p', path, 'index.js'], (err, stdout) => (err ? reject(err) : resolve(stdout)))
})
expect(extracted).toBe('exports.handler = 1')
} finally {
await rm(path, {force: true})
}
})
test('supports multiple entries with distinct offsets', () => {
const archive = createZipArchive([
{name: 'a.js', content: 'a'},
{name: 'b.js', content: 'bb'},
])
const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength)
expect(view.getUint16(archive.length - 22 + 8, true)).toBe(2)
// Second local header follows the first entry's header plus its 1 byte body.
expect(view.getUint32(30 + 4 + 1, true)).toBe(0x04034b50)
})
test('accepts binary content', () => {
const bytes = new Uint8Array([0, 1, 2, 253, 254, 255])
const archive = createZipArchive([{name: 'blob.bin', content: bytes}])
const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength)
expect(view.getUint32(14, true)).toBe(crc32(bytes))
expect(view.getUint32(18, true)).toBe(bytes.length)
})
test('produces a byte-identical archive for identical input', () => {
// No timestamps, so a deployment package is reproducible.
const build = () => createZipArchive([{name: 'index.js', content: 'x'}])
expect(build()).toEqual(build())
})
})
describe('handlerFileName', () => {
test('derives the module file from the handler string', () => {
expect(handlerFileName('index.handler', 'nodejs20.x')).toBe('index.js')
expect(handlerFileName('app.main', 'nodejs18.x')).toBe('app.js')
expect(handlerFileName('src/app.run', 'nodejs20.x')).toBe('src/app.js')
})
test('uses the runtime language extension', () => {
expect(handlerFileName('lambda_function.lambda_handler', 'python3.12')).toBe('lambda_function.py')
expect(handlerFileName('function.handler', 'ruby3.3')).toBe('function.rb')
})
test('falls back to index when the handler has no module part', () => {
expect(handlerFileName('handler', 'nodejs20.x')).toBe('index.js')
})
})
+132
View File
@@ -0,0 +1,132 @@
/**
* Minimal ZIP writer for Lambda deployment packages.
*
* `CreateFunction`'s `Code.ZipFile` must be a real ZIP archive — passing raw
* source text makes the runtime reject the call with "Handler file not found in
* deployment package". Nothing in the dependency tree can build one, and the
* archives here are a single small source file, so entries are written with the
* STORED method (no compression): that needs only a CRC-32 and keeps this to a
* format-exact, dependency-free implementation.
*
* Spec: PKWARE APPNOTE 6.3.x, sections 4.3.7 (local header), 4.3.12 (central
* directory) and 4.3.16 (end of central directory).
*/
const CRC32_TABLE = buildCrc32Table()
function buildCrc32Table(): Uint32Array {
const table = new Uint32Array(256)
for (let i = 0; i < 256; i += 1) {
let value = i
for (let bit = 0; bit < 8; bit += 1) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1
}
table[i] = value >>> 0
}
return table
}
export function crc32(bytes: Uint8Array): number {
let crc = 0xffffffff
for (const byte of bytes) {
crc = CRC32_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8)
}
return (crc ^ 0xffffffff) >>> 0
}
export interface ZipEntry {
name: string
content: string | Uint8Array
}
/** Build a ZIP archive containing the given files, stored uncompressed. */
export function createZipArchive(entries: ZipEntry[]): Uint8Array {
const encoder = new TextEncoder()
const localParts: Uint8Array[] = []
const centralParts: Uint8Array[] = []
let offset = 0
let localSize = 0
for (const entry of entries) {
const nameBytes = encoder.encode(entry.name)
const data = typeof entry.content === 'string' ? encoder.encode(entry.content) : entry.content
const checksum = crc32(data)
const localHeader = new Uint8Array(30 + nameBytes.length)
const localView = new DataView(localHeader.buffer)
localView.setUint32(0, 0x04034b50, true) // local file header signature
localView.setUint16(4, 20, true) // version needed to extract (2.0)
localView.setUint16(6, 0, true) // general purpose bit flag
localView.setUint16(8, 0, true) // compression method: STORED
localView.setUint16(10, 0, true) // last mod time — fixed for reproducibility
localView.setUint16(12, 0x0021, true) // last mod date: 1980-01-01, the epoch ZIP allows
localView.setUint32(14, checksum, true)
localView.setUint32(18, data.length, true) // compressed size
localView.setUint32(22, data.length, true) // uncompressed size
localView.setUint16(26, nameBytes.length, true)
localView.setUint16(28, 0, true) // extra field length
localHeader.set(nameBytes, 30)
const centralHeader = new Uint8Array(46 + nameBytes.length)
const centralView = new DataView(centralHeader.buffer)
centralView.setUint32(0, 0x02014b50, true) // central directory signature
centralView.setUint16(4, 20, true) // version made by
centralView.setUint16(6, 20, true) // version needed to extract
centralView.setUint16(8, 0, true)
centralView.setUint16(10, 0, true) // STORED
centralView.setUint16(12, 0, true)
centralView.setUint16(14, 0x0021, true)
centralView.setUint32(16, checksum, true)
centralView.setUint32(20, data.length, true)
centralView.setUint32(24, data.length, true)
centralView.setUint16(28, nameBytes.length, true)
centralView.setUint16(30, 0, true) // extra field length
centralView.setUint16(32, 0, true) // file comment length
centralView.setUint16(34, 0, true) // disk number start
centralView.setUint16(36, 0, true) // internal file attributes
centralView.setUint32(38, 0o100644 << 16, true) // external attrs: regular file, rw-r--r--
centralView.setUint32(42, offset, true) // relative offset of local header
centralHeader.set(nameBytes, 46)
localParts.push(localHeader, data)
centralParts.push(centralHeader)
offset += localHeader.length + data.length
localSize += localHeader.length + data.length
}
const centralSize = centralParts.reduce((total, part) => total + part.length, 0)
const endRecord = new Uint8Array(22)
const endView = new DataView(endRecord.buffer)
endView.setUint32(0, 0x06054b50, true) // end of central directory signature
endView.setUint16(4, 0, true) // this disk number
endView.setUint16(6, 0, true) // disk with central directory
endView.setUint16(8, entries.length, true)
endView.setUint16(10, entries.length, true)
endView.setUint32(12, centralSize, true)
endView.setUint32(16, localSize, true) // central directory offset
endView.setUint16(20, 0, true) // comment length
return concat([...localParts, ...centralParts, endRecord])
}
function concat(parts: Uint8Array[]): Uint8Array {
const total = parts.reduce((sum, part) => sum + part.length, 0)
const out = new Uint8Array(total)
let cursor = 0
for (const part of parts) {
out.set(part, cursor)
cursor += part.length
}
return out
}
/**
* Derive the deployment-package filename Lambda will look for from a handler
* string. `index.handler` means the exported `handler` lives in `index.js`;
* `src/app.run` means `run` lives in `src/app.js`.
*/
export function handlerFileName(handler: string, runtime: string): string {
const modulePath = handler.split('.').slice(0, -1).join('.') || 'index'
const extension = runtime.startsWith('python') ? 'py' : runtime.startsWith('ruby') ? 'rb' : 'js'
return `${modulePath}.${extension}`
}