feat: Cloudformation + RDS

This commit is contained in:
Zakaria
2026-09-30 17:43:42 -04:00
parent 548a15d7ec
commit 7764ea0dc1
411 changed files with 10182 additions and 1063 deletions
@@ -159,16 +159,16 @@ describe('AwsApiGatewayAdapter', () => {
})
expect(apiCommands.map((command) => (command as {constructor: {name: string}}).constructor.name)).toEqual([
'GetResourcesCommand', 'GetResourcesCommand', 'CreateResourceCommand', 'PutMethodCommand',
'GetResourcesCommand', 'CreateResourceCommand', 'PutMethodCommand',
'PutIntegrationCommand', 'CreateDeploymentCommand',
])
expect((apiCommands[2] as CreateResourceCommand).input).toMatchObject({
expect((apiCommands[1] as CreateResourceCommand).input).toMatchObject({
restApiId: 'a1b2c3', parentId: 'root123', pathPart: 'hello',
})
expect((apiCommands[3] as PutMethodCommand).input).toMatchObject({
expect((apiCommands[2] as PutMethodCommand).input).toMatchObject({
httpMethod: 'GET', authorizationType: 'NONE',
})
expect((apiCommands[4] as PutIntegrationCommand).input).toMatchObject({
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',
})
@@ -182,13 +182,77 @@ describe('AwsApiGatewayAdapter', () => {
})
})
test('creates missing nested resources and reuses existing path segments', async () => {
const apiCommands: unknown[] = []
const adapter = new AwsApiGatewayAdapter(
fakeClient((command) => {
apiCommands.push(command)
if (command instanceof GetResourcesCommand) return {items: [
{id: 'root123', path: '/'},
{id: 'orders123', parentId: 'root123', pathPart: 'orders', path: '/orders'},
]}
if (command instanceof CreateResourceCommand) return {id: 'orderId123'}
if (command instanceof CreateDeploymentCommand) return {id: 'deployment789'}
return {}
}),
fakeLambda((command) => command instanceof GetFunctionCommand
? {Configuration: {FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:hello'}}
: {}),
)
const result = await adapter.createApiGatewayLambdaRoute('a1b2c3', {
path: '/orders/{id}', method: 'PATCH', stage: 'dev', functionName: 'hello',
})
const creates = apiCommands.filter((command) => command instanceof CreateResourceCommand) as CreateResourceCommand[]
expect(creates).toHaveLength(1)
expect(creates[0]?.input).toMatchObject({parentId: 'orders123', pathPart: '{id}'})
expect((apiCommands.find((command) => command instanceof PutMethodCommand) as PutMethodCommand).input.httpMethod).toBe('PATCH')
expect(result).toMatchObject({resourceId: 'orderId123', invokeUrl: 'http://localhost:4566/execute-api/a1b2c3/dev/orders/{id}'})
})
test('creates a greedy child under the explicitly selected parent', async () => {
const commands: unknown[] = []
const lambdaCommands: unknown[] = []
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
commands.push(command)
if (command instanceof GetResourcesCommand) return {items: [
{id: 'root123', path: '/'},
{id: 'files123', parentId: 'root123', pathPart: 'files', path: '/files'},
]}
if (command instanceof CreateResourceCommand) return {id: 'proxy123'}
if (command instanceof CreateDeploymentCommand) return {id: 'deployment789'}
return {}
}), fakeLambda((command) => {
lambdaCommands.push(command)
return command instanceof GetFunctionCommand
? {Configuration: {FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:files'}}
: {}
}))
await adapter.createApiGatewayLambdaRoute('a1b2c3', {
path: '/files/{proxy+}', parentResourceId: 'files123', pathPart: '{proxy+}',
method: 'ANY', stage: 'dev', functionName: 'files',
})
expect((commands.find((command) => command instanceof CreateResourceCommand) as CreateResourceCommand).input).toMatchObject({
parentId: 'files123', pathPart: '{proxy+}',
})
expect((lambdaCommands.find((command) => command instanceof AddPermissionCommand) as AddPermissionCommand).input).toMatchObject({
SourceArn: 'arn:aws:execute-api:us-east-1:000000000000:a1b2c3/dev/*/files/*',
})
})
test('adds a method to an existing resource without creating a duplicate path', async () => {
const apiCommands: unknown[] = []
const adapter = new AwsApiGatewayAdapter(
fakeClient((command) => {
apiCommands.push(command)
if (command instanceof GetResourcesCommand) {
return {items: [{id: 'resource456', path: '/hello'}, {id: 'root123', path: '/'}]}
return {items: [
{id: 'resource456', parentId: 'root123', pathPart: 'hello', path: '/hello'},
{id: 'root123', path: '/'},
]}
}
if (command instanceof CreateDeploymentCommand) return {id: 'deployment789'}
return {}
@@ -264,6 +328,25 @@ describe('AwsApiGatewayAdapter', () => {
}
})
test('uses PATCH and HEAD for deployed requests and rejects ANY', async () => {
const originalFetch = globalThis.fetch
const requests: Request[] = []
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push(new Request(input, init))
return new Response(null)
}) as typeof fetch
try {
const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({})))
await adapter.testApiGatewayRoute('a1b2c3', {path: '/orders/1', method: 'PATCH', stage: 'dev', body: '{}', headers: {}, query: ''})
await adapter.testApiGatewayRoute('a1b2c3', {path: '/orders/1', method: 'HEAD', stage: 'dev', body: '{}', headers: {}, query: ''})
await expect(adapter.testApiGatewayRoute('a1b2c3', {path: '/orders/1', method: 'ANY', stage: 'dev', body: '', headers: {}, query: ''})).rejects.toThrow('method must be')
expect(requests.map((request) => request.method)).toEqual(['PATCH', 'HEAD'])
expect(await requests[1]?.text()).toBe('')
} finally {
globalThis.fetch = originalFetch
}
})
test('tests a configured method through the AWS SDK without a deployed stage', async () => {
const commands: unknown[] = []
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
@@ -283,7 +366,7 @@ describe('AwsApiGatewayAdapter', () => {
await expect(adapter.testApiGatewayMethod('a1b2c3', {
resourceId: 'resource456',
httpMethod: 'POST',
httpMethod: 'ANY',
pathWithQueryString: '/orders?dryRun=true',
body: '{"name":"Ada"}',
headers: {'content-type': 'application/json'},
@@ -298,7 +381,7 @@ describe('AwsApiGatewayAdapter', () => {
latency: 17,
})
expect((commands[0] as TestInvokeMethodCommand).input).toEqual({
restApiId: 'a1b2c3', resourceId: 'resource456', httpMethod: 'POST',
restApiId: 'a1b2c3', resourceId: 'resource456', httpMethod: 'ANY',
pathWithQueryString: '/orders?dryRun=true', body: '{"name":"Ada"}',
headers: {'content-type': 'application/json'}, multiValueHeaders: {'x-tag': ['one', 'two']},
stageVariables: {environment: 'test'},
@@ -310,7 +393,7 @@ describe('AwsApiGatewayAdapter', () => {
if (command instanceof GetResourcesCommand) {
return {items: [
{id: 'root123', path: '/', resourceMethods: {}},
{id: 'resource456', path: '/hello', resourceMethods: {GET: true, POST: true}},
{id: 'resource456', parentId: 'root123', pathPart: 'hello', path: '/hello', resourceMethods: {GET: true, POST: true}},
]}
}
if (command instanceof GetMethodCommand) {
@@ -323,6 +406,13 @@ describe('AwsApiGatewayAdapter', () => {
}))
await expect(adapter.listApiGatewayRoutes('a1b2c3')).resolves.toEqual({
resources: [
{resourceId: 'root123', parentId: null, path: '/', pathPart: null, methods: []},
{resourceId: 'resource456', parentId: 'root123', path: '/hello', pathPart: 'hello', methods: [
{resourceId: 'resource456', path: '/hello', method: 'GET', functionName: 'hello', integrationType: null},
{resourceId: 'resource456', path: '/hello', method: 'POST', functionName: 'hello', integrationType: null},
]},
],
routes: [
{resourceId: 'resource456', path: '/hello', method: 'GET', functionName: 'hello', integrationType: null},
{resourceId: 'resource456', path: '/hello', method: 'POST', functionName: 'hello', integrationType: null},
@@ -331,6 +421,28 @@ describe('AwsApiGatewayAdapter', () => {
})
})
test('paginates and returns resources that have no configured methods', async () => {
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
if (command instanceof GetResourcesCommand && !command.input.position) {
return {items: [{id: 'root123', path: '/', resourceMethods: {}}], position: 'next'}
}
if (command instanceof GetResourcesCommand) {
return {items: [{id: 'orders123', parentId: 'root123', pathPart: 'orders', path: '/orders'}]}
}
if (command instanceof GetStagesCommand) return {item: []}
throw new Error('Unexpected command')
}))
await expect(adapter.listApiGatewayRoutes('a1b2c3')).resolves.toEqual({
resources: [
{resourceId: 'root123', parentId: null, path: '/', pathPart: null, methods: []},
{resourceId: 'orders123', parentId: 'root123', path: '/orders', pathPart: 'orders', methods: []},
],
routes: [],
stages: [],
})
})
test('deletes one method and redeploys its stage', async () => {
const commands: unknown[] = []
const lambdaCommands: unknown[] = []
@@ -18,6 +18,7 @@ import {
type Integration,
type IntegrationResponse,
type MethodResponse,
type Resource,
type RestApi,
} from '@aws-sdk/client-api-gateway'
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
@@ -82,18 +83,18 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
}
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')
if (!CONFIGURABLE_METHODS.includes(method)) {
throw new ValidationError('method must be GET, POST, PUT, DELETE, PATCH, ANY, or HEAD')
}
const resource = await this.resourceForPath(apiId, `/${pathPart}`) ?? await this.createTopLevelResource(apiId, pathPart)
const resource = await this.resolveRouteResource(apiId, input)
const resourceId = resource.id
if (!resourceId) throw new ValidationError('API Gateway did not return a resource id')
const resourcePath = resource.path ?? routePath(input.path)
await this.apiGateway.send(new PutMethodCommand({
restApiId: apiId,
@@ -116,10 +117,10 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
const permission = {
FunctionName: functionName,
StatementId: apiGatewayPermissionStatementId(apiId, stage, method, `/${pathPart}`),
StatementId: apiGatewayPermissionStatementId(apiId, stage, method, resourcePath),
Action: 'lambda:InvokeFunction',
Principal: 'apigateway.amazonaws.com',
SourceArn: `arn:aws:execute-api:${this.region}:${this.accountId}:${apiId}/${stage}/${method}/${pathPart}`,
SourceArn: apiGatewaySourceArn(this.region, this.accountId, apiId, stage, method, resourcePath),
}
try {
await this.lambda.send(new AddPermissionCommand(permission))
@@ -135,14 +136,14 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
const deployment = await this.apiGateway.send(new CreateDeploymentCommand({
restApiId: apiId,
stageName: stage,
description: `Lambda proxy route ${method} /${pathPart}`,
description: `Lambda proxy route ${method} ${resourcePath}`,
}))
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}`,
invokeUrl: `${awsEndpoint().replace(/\/$/, '')}/execute-api/${apiId}/${stage}${resourcePath}`,
}
}
@@ -150,8 +151,8 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
const path = testRoutePath(input.path)
const method = input.method.toUpperCase()
const stage = stageName(input.stage)
if (!['GET', 'POST', 'PUT', 'DELETE'].includes(method)) {
throw new ValidationError('method must be GET, POST, PUT, or DELETE')
if (!DEPLOYED_HTTP_METHODS.includes(method)) {
throw new ValidationError('method must be GET, POST, PUT, DELETE, PATCH, or HEAD')
}
const encodedPath = path.split('/').map(encodeURIComponent).join('/')
@@ -179,8 +180,8 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
const httpMethod = input.httpMethod.toUpperCase()
const pathWithQueryString = stringValue(input.pathWithQueryString)
if (!resourceId) throw new ValidationError('resourceId is required')
if (!['GET', 'POST', 'PUT', 'DELETE'].includes(httpMethod)) {
throw new ValidationError('httpMethod must be GET, POST, PUT, or DELETE')
if (!TEST_INVOKE_METHODS.includes(httpMethod)) {
throw new ValidationError('httpMethod must be a configured API Gateway method')
}
if (!pathWithQueryString.startsWith('/')) {
throw new ValidationError('pathWithQueryString must start with /')
@@ -207,19 +208,13 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
}
async listApiGatewayRoutes(apiId: string): Promise<ApiGatewayRoutesResult> {
const resources = []
let position: string | undefined
do {
const result = await this.apiGateway.send(new GetResourcesCommand({restApiId: apiId, position, embed: ['methods']}))
resources.push(...(result.items ?? []))
position = result.position
} while (position)
const resources = await this.resources(apiId, true)
const routes: ApiGatewayRouteOverview[] = []
for (const resource of resources) {
const methods = resource.resourceMethods
? Object.keys(resource.resourceMethods).sort()
: ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT']
: []
for (const method of methods) {
if (!resource.id || !resource.path) continue
let result
@@ -244,7 +239,18 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
}
const stages = await this.apiGateway.send(new GetStagesCommand({restApiId: apiId}))
const resourcesOverview = resources
.filter((resource): resource is Resource & {id: string; path: string} => Boolean(resource.id && resource.path))
.map((resource) => ({
resourceId: resource.id,
parentId: resource.parentId ?? null,
path: resource.path,
pathPart: resource.pathPart ?? null,
methods: routes.filter((route) => route.resourceId === resource.id),
}))
.sort((left, right) => left.path.localeCompare(right.path))
return {
resources: resourcesOverview,
routes: routes.sort((left, right) => left.path.localeCompare(right.path) || left.method.localeCompare(right.method)),
stages: (stages.item ?? []).map((stage) => stage.stageName).filter((stage): stage is string => Boolean(stage)).sort(),
}
@@ -253,7 +259,7 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
async deleteApiGatewayRoute(apiId: string, input: ApiGatewayDeleteRouteInput): Promise<void> {
const method = input.method.toUpperCase()
const stage = stageName(input.stage)
if (!CORS_METHODS.includes(method)) throw new ValidationError('method must be GET, POST, PUT, DELETE, or OPTIONS')
if (!DELETABLE_METHODS.includes(method)) throw new ValidationError('method is not supported')
if (!stringValue(input.resourceId)) throw new ValidationError('resourceId is required')
const route = await this.apiGateway.send(new GetMethodCommand({
@@ -294,7 +300,7 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
if (!origin) throw new ValidationError('origin is required')
if (!headers) throw new ValidationError('headers are required')
if (methods.length === 0 || methods.some((method) => !CORS_METHODS.includes(method))) {
throw new ValidationError('methods must contain one or more of GET, POST, PUT, DELETE, or OPTIONS')
throw new ValidationError('methods must contain one or more supported HTTP methods')
}
const corsParameters = corsResponseParameters(origin, headers, methods)
@@ -371,39 +377,57 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
return {configuredMethods, proxyMethods}
}
private async rootResource(apiId: string) {
private async resources(apiId: string, embedMethods = false): Promise<Resource[]> {
const resources: Resource[] = []
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
const result = await this.apiGateway.send(new GetResourcesCommand({
restApiId: apiId,
position,
embed: embedMethods ? ['methods'] : undefined,
}))
resources.push(...(result.items ?? []))
position = result.position
} while (position)
throw new ValidationError('API Gateway root resource was not found')
return resources
}
private async resourceForPath(apiId: string, path: string) {
let position: string | undefined
do {
const result = await this.apiGateway.send(new GetResourcesCommand({restApiId: apiId, position}))
const resource = result.items?.find((item) => item.path === path)
if (resource?.id) return resource
position = result.position
} while (position)
return undefined
}
private async resolveRouteResource(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<Resource> {
const resources = await this.resources(apiId)
if (input.parentResourceId || input.pathPart) {
const parentId = stringValue(input.parentResourceId)
const pathPart = routePathPart(input.pathPart ?? '')
const parent = resources.find((resource) => resource.id === parentId)
if (!parent?.id || !parent.path) throw new ValidationError('parentResourceId must identify an existing API Gateway resource')
const existing = resources.find((resource) => resource.parentId === parent.id && resource.pathPart === pathPart)
if (existing?.id) return existing
const created = await this.apiGateway.send(new CreateResourceCommand({restApiId: apiId, parentId: parent.id, pathPart}))
return {...created, pathPart, parentId: parent.id, path: childPath(parent.path, pathPart)}
}
private async createTopLevelResource(apiId: string, pathPart: string) {
const root = await this.rootResource(apiId)
return this.apiGateway.send(new CreateResourceCommand({
restApiId: apiId,
parentId: root.id,
pathPart,
}))
const parts = routePath(input.path).slice(1).split('/')
const root = resources.find((resource) => resource.path === '/')
if (!root?.id) throw new ValidationError('API Gateway root resource was not found')
let parent: Resource = root
for (const pathPart of parts) {
const existing = resources.find((resource) => resource.parentId === parent.id && resource.pathPart === pathPart)
if (existing?.id) {
parent = existing
continue
}
const created: Resource = await this.apiGateway.send(new CreateResourceCommand({restApiId: apiId, parentId: parent.id, pathPart}))
parent = {...created, pathPart, parentId: parent.id, path: childPath(parent.path ?? '/', pathPart)}
resources.push(parent)
}
return parent
}
}
const CORS_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
const CONFIGURABLE_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'ANY', 'HEAD']
const DEPLOYED_HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD']
const TEST_INVOKE_METHODS = [...CONFIGURABLE_METHODS, 'OPTIONS']
const CORS_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']
const DELETABLE_METHODS = [...CONFIGURABLE_METHODS, 'OPTIONS']
function corsMethodResponseParameters(): Record<string, boolean> {
return {
@@ -424,13 +448,26 @@ function corsResponseParameters(origin: string, headers: string, methods: string
}
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')
const value = stringValue(path)
if (!/^(?:[A-Za-z0-9._-]+|\{[A-Za-z0-9._-]+\+?})$/.test(value)) {
throw new ValidationError('pathPart must be one API Gateway segment, for example orders, {id}, or {proxy+}')
}
return value
}
function routePath(path: string): string {
const value = stringValue(path)
if (!value.startsWith('/') || value === '/' || value.endsWith('/')) {
throw new ValidationError('path must start with / and contain at least one resource segment')
}
for (const part of value.slice(1).split('/')) routePathPart(part)
return value
}
function childPath(parentPath: string, pathPart: string): string {
return parentPath === '/' ? `/${pathPart}` : `${parentPath}/${pathPart}`
}
function stageName(stage: string): string {
const value = stringValue(stage)
if (!/^[A-Za-z0-9_-]+$/.test(value)) {
@@ -447,7 +484,7 @@ function queryString(query: string): string {
function testRoutePath(path: string): string {
const value = stringValue(path)
if (!/^\/(?:[A-Za-z0-9._~{}-]+\/)*[A-Za-z0-9._~{}-]+$/.test(value)) {
if (!/^\/(?:[A-Za-z0-9._~{}+%-]+\/)*[A-Za-z0-9._~{}+%-]+$/.test(value)) {
throw new ValidationError('path must be an API Gateway route path, for example /orders or /users/{id}')
}
return value
@@ -459,7 +496,13 @@ function lambdaFunctionName(uri?: string): string | null {
}
function apiGatewayPermissionStatementId(apiId: string, stage: string, method: string, path: string): string {
return `apigateway-${apiId}-${stage}-${method}-${routePathPart(path)}`
return `apigateway-${apiId}-${stage}-${method}-${path.replace(/[^A-Za-z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')}`.slice(0, 100)
}
function apiGatewaySourceArn(region: string, accountId: string, apiId: string, stage: string, method: string, path: string): string {
const arnMethod = method === 'ANY' ? '*' : method
const arnPath = path.slice(1).split('/').map((part) => part.startsWith('{') ? '*' : part).join('/')
return `arn:aws:execute-api:${region}:${accountId}:${apiId}/${stage}/${arnMethod}/${arnPath}`
}
function toResource(api: RestApi): CloudResource {
@@ -1,10 +1,15 @@
import {describe, expect, test} from 'bun:test'
import {
CreateStackCommand,
CreateChangeSetCommand,
DescribeChangeSetCommand,
DeleteStackCommand,
DescribeStackEventsCommand,
DescribeStacksCommand,
GetTemplateCommand,
ListStackResourcesCommand,
ListStacksCommand,
UpdateStackCommand,
type CloudFormationClient,
} from '@aws-sdk/client-cloudformation'
import {AwsCloudFormationAdapter} from './AwsCloudFormationAdapter'
@@ -81,6 +86,8 @@ describe('AwsCloudFormationAdapter', () => {
const adapter = new AwsCloudFormationAdapter(fakeClient((command) => {
if (command instanceof DescribeStacksCommand) return {Stacks: [ordersStack]}
if (command instanceof ListStackResourcesCommand) return {StackResourceSummaries: [bucketResource]}
if (command instanceof DescribeStackEventsCommand) return {StackEvents: [{LogicalResourceId: 'OrdersBucket', ResourceStatus: 'CREATE_COMPLETE', Timestamp: new Date('2026-01-02T03:04:07.000Z')}]}
if (command instanceof GetTemplateCommand) return {TemplateBody: '{"Resources":{}}'}
throw new Error('Unexpected command')
}))
@@ -96,6 +103,7 @@ describe('AwsCloudFormationAdapter', () => {
resourceType: 'AWS::S3::Bucket',
status: 'CREATE_COMPLETE',
}])
expect(result?.metadata.templateBody).toBe('{"Resources":{}}')
})
test('paginates ListStackResources and combines resources from every page', async () => {
@@ -107,6 +115,8 @@ describe('AwsCloudFormationAdapter', () => {
}
return {StackResourceSummaries: [{...bucketResource, LogicalResourceId: 'OrdersQueue', PhysicalResourceId: 'orders-queue', ResourceType: 'AWS::SQS::Queue'}]}
}
if (command instanceof DescribeStackEventsCommand) return {StackEvents: []}
if (command instanceof GetTemplateCommand) return {TemplateBody: '{}'}
throw new Error('Unexpected command')
}))
@@ -153,6 +163,43 @@ describe('AwsCloudFormationAdapter', () => {
expect(result.metadata.stackId).toBe(ordersStack.StackId)
})
test('passes parameters and capabilities to CloudFormation', async () => {
const adapter = new AwsCloudFormationAdapter(fakeClient((command) => {
if (command instanceof CreateStackCommand) {
expect(command.input).toMatchObject({
Parameters: [{ParameterKey: 'Environment', ParameterValue: 'test'}],
Capabilities: ['CAPABILITY_NAMED_IAM'],
})
return {StackId: ordersStack.StackId}
}
if (command instanceof CreateChangeSetCommand) {
expect(command.input).toMatchObject({
Parameters: [{ParameterKey: 'Environment', ParameterValue: 'test'}],
Capabilities: ['CAPABILITY_NAMED_IAM'],
})
return {Id: 'change-set-id'}
}
if (command instanceof DescribeChangeSetCommand) return {ChangeSetName: 'planned', Status: 'CREATE_COMPLETE', ExecutionStatus: 'AVAILABLE'}
throw new Error('Unexpected command')
}))
await adapter.create({values: {stackName: 'orders-stack', templateBody: '{}', parameters: '{"Environment":"test"}', capabilities: 'CAPABILITY_NAMED_IAM'}})
await adapter.createCloudFormationChangeSet('orders-stack', {name: 'planned', templateBody: '{}', parameters: {Environment: 'test'}, capabilities: ['CAPABILITY_NAMED_IAM']})
})
test('creates a stack from an S3 template URL', async () => {
const adapter = new AwsCloudFormationAdapter(fakeClient((command) => {
expect(command).toBeInstanceOf(CreateStackCommand)
expect((command as CreateStackCommand).input).toEqual({
StackName: 'orders-stack',
TemplateURL: 'https://templates.s3.amazonaws.com/orders.yaml',
})
return {StackId: ordersStack.StackId}
}))
await expect(adapter.create({values: {stackName: 'orders-stack', templateUrl: 'https://templates.s3.amazonaws.com/orders.yaml'}})).resolves.toMatchObject({id: 'orders-stack'})
})
test('rejects a missing stack name before calling CloudFormation', async () => {
let called = false
const adapter = new AwsCloudFormationAdapter(fakeClient(() => {
@@ -164,14 +211,14 @@ describe('AwsCloudFormationAdapter', () => {
expect(called).toBeFalse()
})
test('rejects a missing template body before calling CloudFormation', async () => {
test('rejects missing template input 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')
await expect(adapter.create({values: {stackName: 'orders-stack'}})).rejects.toThrow('templateBody or templateUrl is required')
expect(called).toBeFalse()
})
@@ -185,6 +232,22 @@ describe('AwsCloudFormationAdapter', () => {
await adapter.delete('orders-stack')
})
test('updates a stack template then reads back the stack workspace', async () => {
const adapter = new AwsCloudFormationAdapter(fakeClient((command) => {
if (command instanceof UpdateStackCommand) {
expect(command.input).toEqual({StackName: 'orders-stack', TemplateBody: '{"Resources":{"Queue":{"Type":"AWS::SQS::Queue"}}}'})
return {StackId: ordersStack.StackId}
}
if (command instanceof DescribeStacksCommand) return {Stacks: [ordersStack]}
if (command instanceof ListStackResourcesCommand) return {StackResourceSummaries: []}
if (command instanceof DescribeStackEventsCommand) return {StackEvents: []}
if (command instanceof GetTemplateCommand) return {TemplateBody: '{}'}
throw new Error('Unexpected command')
}))
await expect(adapter.update('orders-stack', {values: {templateBody: '{"Resources":{"Queue":{"Type":"AWS::SQS::Queue"}}}'}})).resolves.toMatchObject({id: 'orders-stack'})
})
test('returns the AWS CloudFormation schema', () => {
const adapter = new AwsCloudFormationAdapter(fakeClient(() => ({})))
@@ -192,7 +255,7 @@ describe('AwsCloudFormationAdapter', () => {
cloud: 'aws',
service: 'iac',
displayName: 'CloudFormation',
actions: ['list', 'create', 'delete', 'inspect'],
actions: ['list', 'create', 'update', 'delete', 'inspect'],
})
})
})
@@ -2,17 +2,28 @@ import {
CloudFormationClient,
CreateStackCommand,
DeleteStackCommand,
CreateChangeSetCommand,
DeleteChangeSetCommand,
DescribeStackEventsCommand,
DescribeChangeSetCommand,
DescribeStacksCommand,
GetTemplateCommand,
ExecuteChangeSetCommand,
ListStackResourcesCommand,
ListStacksCommand,
ListChangeSetsCommand,
UpdateStackCommand,
UpdateTerminationProtectionCommand,
type Stack,
type DescribeChangeSetOutput,
type Capability,
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'
import type {CloudFormationChangeSet, CloudFormationChangeSetInput, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema, UpdateResourceInput} from '../cloud-spi/types'
export class AwsCloudFormationAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
@@ -51,7 +62,11 @@ export class AwsCloudFormationAdapter implements CloudServiceAdapter {
nextToken = resources.NextToken
} while (nextToken)
return stackToResource(stack, resourceSummaries)
const [events, template] = await Promise.all([
this.cloudformation.send(new DescribeStackEventsCommand({StackName: id})),
this.cloudformation.send(new GetTemplateCommand({StackName: id, TemplateStage: 'Original'})),
])
return stackToResource(stack, resourceSummaries, events.StackEvents ?? [], template.TemplateBody)
} catch (error) {
if (isStackNotFound(error)) return null
throw error
@@ -61,10 +76,12 @@ export class AwsCloudFormationAdapter implements CloudServiceAdapter {
async create(input: CreateResourceInput): Promise<CloudResource> {
const stackName = stringValue(input.values.stackName)
const templateBody = stringValue(input.values.templateBody)
const templateUrl = stringValue(input.values.templateUrl)
if (!stackName) throw new ValidationError('stackName is required')
if (!templateBody) throw new ValidationError('templateBody is required')
if (!templateBody && !templateUrl) throw new ValidationError('templateBody or templateUrl is required')
const res = await this.cloudformation.send(new CreateStackCommand({StackName: stackName, TemplateBody: templateBody}))
const options = stackOptions(input.values)
const res = await this.cloudformation.send(new CreateStackCommand({StackName: stackName, ...(templateBody ? {TemplateBody: templateBody} : {TemplateURL: templateUrl}), ...options}))
return {
id: stackName,
name: stackName,
@@ -84,6 +101,46 @@ export class AwsCloudFormationAdapter implements CloudServiceAdapter {
async delete(id: string): Promise<void> {
await this.cloudformation.send(new DeleteStackCommand({StackName: id}))
}
async update(id: string, input: UpdateResourceInput): Promise<CloudResource> {
const templateBody = stringValue(input.values.templateBody)
if (!templateBody) throw new ValidationError('templateBody is required')
await this.cloudformation.send(new UpdateStackCommand({StackName: id, TemplateBody: templateBody, ...stackOptions(input.values)}))
const stack = await this.get(id)
if (!stack) throw new ValidationError(`Stack ${id} was updated but could not be read back.`)
return stack
}
async listCloudFormationChangeSets(stackName: string): Promise<CloudFormationChangeSet[]> {
const response = await this.cloudformation.send(new ListChangeSetsCommand({StackName: stackName}))
return Promise.all((response.Summaries ?? []).filter((summary) => Boolean(summary.ChangeSetName)).map(async (summary) => {
const name = summary.ChangeSetName as string
const detail = await this.cloudformation.send(new DescribeChangeSetCommand({StackName: stackName, ChangeSetName: name}))
return changeSet(detail, name)
}))
}
async createCloudFormationChangeSet(stackName: string, input: CloudFormationChangeSetInput): Promise<CloudFormationChangeSet> {
const name = stringValue(input.name)
const templateBody = stringValue(input.templateBody)
if (!name || !templateBody) throw new ValidationError('change set name and templateBody are required')
const capabilities = capabilitiesValue(input.capabilities)
const created = await this.cloudformation.send(new CreateChangeSetCommand({StackName: stackName, ChangeSetName: name, ChangeSetType: 'UPDATE', TemplateBody: templateBody, ...(input.parameters ? {Parameters: parameterEntries(input.parameters)} : {}), ...(capabilities.length ? {Capabilities: capabilities} : {})}))
const detail = await this.cloudformation.send(new DescribeChangeSetCommand({StackName: stackName, ChangeSetName: created.Id ?? name}))
return changeSet(detail, name)
}
async executeCloudFormationChangeSet(stackName: string, changeSetName: string): Promise<void> {
await this.cloudformation.send(new ExecuteChangeSetCommand({StackName: stackName, ChangeSetName: changeSetName}))
}
async deleteCloudFormationChangeSet(stackName: string, changeSetName: string): Promise<void> {
await this.cloudformation.send(new DeleteChangeSetCommand({StackName: stackName, ChangeSetName: changeSetName}))
}
async updateCloudFormationTerminationProtection(stackName: string, enabled: boolean): Promise<void> {
await this.cloudformation.send(new UpdateTerminationProtectionCommand({StackName: stackName, EnableTerminationProtection: enabled}))
}
}
function summaryToResource(summary: StackSummary): CloudResource {
@@ -105,7 +162,7 @@ function summaryToResource(summary: StackSummary): CloudResource {
}
}
function stackToResource(stack: Stack, resources: StackResourceSummary[]): CloudResource {
function stackToResource(stack: Stack, resources: StackResourceSummary[], events: Array<{Timestamp?: Date; ResourceStatus?: string; LogicalResourceId?: string; ResourceStatusReason?: string}> = [], templateBody?: string): CloudResource {
const id = stack.StackName ?? ''
return {
id,
@@ -122,6 +179,8 @@ function stackToResource(stack: Stack, resources: StackResourceSummary[]): Cloud
description: stack.Description,
lastUpdatedTime: stack.LastUpdatedTime?.toISOString(),
capabilities: stack.Capabilities,
templateBody: templateBody ?? null,
parameters: (stack.Parameters ?? []).map((parameter) => ({key: parameter.ParameterKey, value: parameter.ParameterValue})),
enableTerminationProtection: stack.EnableTerminationProtection,
outputs: (stack.Outputs ?? []).map((output) => ({
key: output.OutputKey,
@@ -135,14 +194,57 @@ function stackToResource(stack: Stack, resources: StackResourceSummary[]): Cloud
resourceType: resource.ResourceType,
status: resource.ResourceStatus,
})),
events: events.map((event) => ({timestamp: event.Timestamp?.toISOString(), status: event.ResourceStatus, logicalId: event.LogicalResourceId, reason: event.ResourceStatusReason})),
},
}
}
function changeSet(detail: DescribeChangeSetOutput, fallbackName: string): CloudFormationChangeSet {
return {
name: detail.ChangeSetName ?? fallbackName,
id: detail.ChangeSetId ?? detail.ChangeSetName ?? fallbackName,
status: detail.Status ?? null,
executionStatus: detail.ExecutionStatus ?? null,
changes: (detail.Changes ?? []).map((change) => ({
action: change.ResourceChange?.Action ?? null,
logicalResourceId: change.ResourceChange?.LogicalResourceId ?? null,
resourceType: change.ResourceChange?.ResourceType ?? null,
})),
}
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function stackOptions(values: Record<string, unknown>): {Parameters?: Array<{ParameterKey: string; ParameterValue: string}>; Capabilities?: Capability[]} {
const parameters = parametersValue(values.parameters)
const capabilities = capabilitiesValue(values.capabilities)
return {...(Object.keys(parameters).length ? {Parameters: parameterEntries(parameters)} : {}), ...(capabilities.length ? {Capabilities: capabilities} : {})}
}
function parametersValue(value: unknown): Record<string, string> {
if (!value) return {}
if (typeof value === 'object' && !Array.isArray(value)) return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string'))
if (typeof value !== 'string' || !value.trim()) return {}
try {
const parsed: unknown = JSON.parse(value)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.values(parsed).some((item) => typeof item !== 'string')) throw new Error()
return parsed as Record<string, string>
} catch {
throw new ValidationError('parameters must be a JSON object with string values')
}
}
function capabilitiesValue(value: unknown): Capability[] {
const capabilities = Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) : typeof value === 'string' ? value.split(',').map((item) => item.trim()).filter(Boolean) : []
return capabilities as Capability[]
}
function parameterEntries(parameters: Record<string, string>): Array<{ParameterKey: string; ParameterValue: string}> {
return Object.entries(parameters).map(([ParameterKey, ParameterValue]) => ({ParameterKey, ParameterValue}))
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
@@ -1,17 +1,19 @@
import {describe, expect, test} from 'bun:test'
import {
CreateTableCommand,
DeleteItemCommand,
DeleteTableCommand,
DescribeTableCommand,
ListTablesCommand,
PutItemCommand,
QueryCommand,
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
type Command = CreateTableCommand | DeleteItemCommand | DeleteTableCommand | DescribeTableCommand | ListTablesCommand | PutItemCommand | QueryCommand | ScanCommand
function fakeClient(send: (command: Command) => Promise<unknown>): DynamoDBClient {
return {send} as unknown as DynamoDBClient
@@ -192,7 +194,43 @@ describe('AwsDynamoDbAdapter', () => {
expect(captured?.input.TableName).toBe('orders')
})
test('lists and unmarshalls table records across scan pages', async () => {
test('deletes an item with its typed DynamoDB key', async () => {
let captured: DeleteItemCommand | 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'}],
}}
captured = command as DeleteItemCommand
return {}
})
await new AwsDynamoDbAdapter(client).items.deleteItem!('orders', '{"accountId":"acct-1","sequence":"9007199254740993"}')
expect(captured?.input).toEqual({
TableName: 'orders',
Key: {accountId: {S: 'acct-1'}, sequence: {N: '9007199254740993'}},
})
})
test('does not delete an item without its complete primary key', async () => {
let deleteCalled = false
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 DeleteItemCommand) deleteCalled = true
return {}
})
await expect(new AwsDynamoDbAdapter(client).items.deleteItem!('orders', '{"accountId":"acct-1"}'))
.rejects.toThrow('Key attribute sequence is required')
expect(deleteCalled).toBe(false)
})
test('lists one unmarshalled scan page and returns an opaque cursor', async () => {
const client = fakeClient(async (command) => {
if (command instanceof DescribeTableCommand) {
return {Table: {KeySchema: [
@@ -201,31 +239,25 @@ describe('AwsDynamoDbAdapter', () => {
]}}
}
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'}},
}
expect(command.input.Limit).toBe(100)
return {
Items: [{accountId: {S: 'acct-1'}, createdAt: {N: '1'}, active: {BOOL: true}}],
LastEvaluatedKey: {accountId: {S: 'acct-1'}, createdAt: {N: '1'}}, Count: 1, ScannedCount: 3,
ConsumedCapacity: {CapacityUnits: 0.5},
}
}
throw new Error('Unexpected command')
})
const result = await new AwsDynamoDbAdapter(client).listNoSqlItems('orders')
const result = await new AwsDynamoDbAdapter(client).items.listItems('orders')
expect(result).toEqual([
expect(result).toMatchObject({items: [
{
id: '{"accountId":"acct-1","createdAt":1}',
key: {accountId: 'acct-1', createdAt: 1},
document: {accountId: 'acct-1', createdAt: 1, active: true},
id: '{"accountId":"acct-1","createdAt":"1"}', collectionId: null, timestamp: null,
body: {accountId: 'acct-1', createdAt: 1, active: true}, metadata: {key: {accountId: 'acct-1', createdAt: '1'}},
},
{
id: '{"accountId":"acct-2","createdAt":"9007199254740993"}',
key: {accountId: 'acct-2', createdAt: '9007199254740993'},
document: {accountId: 'acct-2', createdAt: '9007199254740993', active: false, roles: ['admin', 'editor']},
},
])
], count: 1, scannedCount: 3, consumedCapacity: 0.5})
expect(result.nextCursor).toBeString()
})
test('stops scanning after 100 records', async () => {
@@ -245,10 +277,84 @@ describe('AwsDynamoDbAdapter', () => {
throw new Error('Unexpected command')
})
const result = await new AwsDynamoDbAdapter(client).listNoSqlItems('orders')
const result = await new AwsDynamoDbAdapter(client).items.listItems('orders')
expect(result).toHaveLength(100)
expect(result.items).toHaveLength(100)
expect(scanCount).toBe(1)
expect(result.nextCursor).toBeString()
})
test('round-trips typed pagination keys through the opaque cursor', async () => {
const scans: ScanCommand[] = []
const client = fakeClient(async (command) => {
if (command instanceof DescribeTableCommand) return {Table: {
KeySchema: [{AttributeName: 'pk', KeyType: 'HASH'}, {AttributeName: 'sk', KeyType: 'RANGE'}],
AttributeDefinitions: [{AttributeName: 'pk', AttributeType: 'B'}, {AttributeName: 'sk', AttributeType: 'N'}],
}}
if (command instanceof ScanCommand) {
scans.push(command)
return scans.length === 1 ? {LastEvaluatedKey: {pk: {B: Buffer.from('key')}, sk: {N: '9007199254740993'}}} : {}
}
throw new Error('Unexpected command')
})
const adapter = new AwsDynamoDbAdapter(client)
const first = await adapter.items.listItems('orders', {limit: 10})
await adapter.items.listItems('orders', {limit: 10, cursor: first.nextCursor!})
expect(first.nextCursor).not.toContain('9007199254740993')
expect(scans[1].input.ExclusiveStartKey).toEqual({pk: {B: Buffer.from('key')}, sk: {N: '9007199254740993'}})
await expect(adapter.items.listItems('other', {cursor: first.nextCursor!})).rejects.toThrow('cursor is not valid')
})
test('builds a typed server-side scan filter', async () => {
let captured: ScanCommand | undefined
const client = fakeClient(async (command) => {
if (command instanceof DescribeTableCommand) return {Table: {KeySchema: [{AttributeName: 'id', KeyType: 'HASH'}]}}
captured = command as ScanCommand
return {Count: 0, ScannedCount: 8}
})
const result = await new AwsDynamoDbAdapter(client).items.queryItems!('orders', {
operation: 'scan', filter: {attribute: 'total', operator: 'BETWEEN', type: 'N', value: '10.5', secondValue: '20'},
}, {limit: 25})
expect(captured?.input).toMatchObject({
TableName: 'orders', Limit: 25, FilterExpression: '#filter BETWEEN :filter AND :filter2',
ExpressionAttributeNames: {'#filter': 'total'},
ExpressionAttributeValues: {':filter': {N: '10.5'}, ':filter2': {N: '20'}},
})
expect(result).toMatchObject({count: 0, scannedCount: 8})
})
test('queries a selected GSI with typed partition and sort conditions', async () => {
let captured: QueryCommand | undefined
const client = fakeClient(async (command) => {
if (command instanceof DescribeTableCommand) return {Table: {
KeySchema: [{AttributeName: 'id', KeyType: 'HASH'}],
AttributeDefinitions: [
{AttributeName: 'id', AttributeType: 'S'}, {AttributeName: 'accountId', AttributeType: 'S'}, {AttributeName: 'sequence', AttributeType: 'N'},
],
GlobalSecondaryIndexes: [{IndexName: 'account-sequence', KeySchema: [
{AttributeName: 'accountId', KeyType: 'HASH'}, {AttributeName: 'sequence', KeyType: 'RANGE'},
]}],
}}
captured = command as QueryCommand
return {Items: [{id: {S: 'order-1'}, accountId: {S: 'acct-1'}, sequence: {N: '12'}}], Count: 1}
})
const result = await new AwsDynamoDbAdapter(client).items.queryItems!('orders', {
operation: 'query', indexName: 'account-sequence', partitionKeyValue: 'acct-1',
sortKeyCondition: {operator: 'BETWEEN', value: '10', secondValue: '20'},
}, {limit: 15})
expect(captured?.input).toMatchObject({
TableName: 'orders', IndexName: 'account-sequence', Limit: 15,
KeyConditionExpression: '#pk = :pk AND #sk BETWEEN :sk AND :sk2',
ExpressionAttributeNames: {'#pk': 'accountId', '#sk': 'sequence'},
ExpressionAttributeValues: {':pk': {S: 'acct-1'}, ':sk': {N: '10'}, ':sk2': {N: '20'}},
})
expect(result.items[0].metadata.key).toEqual({id: 'order-1'})
})
test('puts a record using the table key types', async () => {
@@ -273,7 +379,7 @@ describe('AwsDynamoDbAdapter', () => {
throw new Error('Unexpected command')
})
const result = await new AwsDynamoDbAdapter(client).putNoSqlItem('orders', {
const result = await new AwsDynamoDbAdapter(client).items.putItem('orders', {
accountId: 'acct-1',
sequence: '9007199254740993',
active: true,
@@ -289,8 +395,10 @@ describe('AwsDynamoDbAdapter', () => {
})
expect(result).toEqual({
id: '{"accountId":"acct-1","sequence":"9007199254740993"}',
key: {accountId: 'acct-1', sequence: '9007199254740993'},
document: {accountId: 'acct-1', sequence: '9007199254740993', active: true},
collectionId: null,
timestamp: null,
body: {accountId: 'acct-1', sequence: '9007199254740993', active: true},
metadata: {key: {accountId: 'acct-1', sequence: '9007199254740993'}},
})
})
@@ -307,10 +415,32 @@ describe('AwsDynamoDbAdapter', () => {
return {}
})
const result = await new AwsDynamoDbAdapter(client).putNoSqlItem('blobs', {id: 'aGVsbG8=', name: 'hello'})
const result = await new AwsDynamoDbAdapter(client).items.putItem('blobs', {id: 'aGVsbG8=', name: 'hello'})
expect(captured?.input.Item?.id.B).toEqual(Buffer.from('hello'))
expect(result.key).toEqual({id: 'aGVsbG8='})
expect(result.metadata.key).toEqual({id: 'aGVsbG8='})
})
test('puts DynamoDB AttributeValue JSON without remarshalshalling typed attributes', 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 {}
})
await new AwsDynamoDbAdapter(client).items.putItem('orders', {
id: {S: 'order-1'}, count: {N: '9007199254740993'}, tags: {SS: ['new', 'priority']},
payload: {B: 'aGVsbG8='}, details: {M: {active: {BOOL: true}}}, entries: {L: [{NULL: true}]},
})
expect(captured?.input.Item).toEqual({
id: {S: 'order-1'}, count: {N: '9007199254740993'}, tags: {SS: ['new', 'priority']},
payload: {B: Buffer.from('hello')}, details: {M: {active: {BOOL: true}}}, entries: {L: [{NULL: true}]},
})
})
test('validates required and typed keys before putting a record', async () => {
@@ -327,10 +457,10 @@ describe('AwsDynamoDbAdapter', () => {
})
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')
await expect(adapter.items.putItem('orders', {})).rejects.toThrow('Key attribute id is required')
await expect(adapter.items.putItem('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)}))
await expect(adapter.items.putItem('orders', {id: JSON.parse(json)}))
.rejects.toThrow('must be a quoted number to preserve precision')
}
expect(putCalls).toBe(0)
@@ -351,12 +481,12 @@ describe('AwsDynamoDbAdapter', () => {
})
const adapter = new AwsDynamoDbAdapter(client)
const created = await adapter.putNoSqlItem('orders', {id})
const listed = await adapter.listNoSqlItems('orders')
const created = await adapter.items.putItem('orders', {id})
const listed = await adapter.items.listItems('orders')
expect(captured?.input.Item?.id).toEqual({N: id})
expect(created).toEqual({id: JSON.stringify({id}), key: {id}, document: {id}})
expect(listed).toEqual([created])
expect(created).toEqual({id: JSON.stringify({id}), collectionId: null, timestamp: null, body: {id}, metadata: {key: {id}}})
expect(listed.items).toEqual([created])
})
test.each([
@@ -377,7 +507,7 @@ describe('AwsDynamoDbAdapter', () => {
return {}
})
await expect(new AwsDynamoDbAdapter(client).putNoSqlItem('orders', JSON.parse(json)))
await expect(new AwsDynamoDbAdapter(client).items.putItem('orders', JSON.parse(json)))
.rejects.toThrow(new ValidationError(`Item value at ${path} must quote integers outside JavaScript's safe range.`))
expect(putCalls).toBe(0)
})
@@ -421,12 +551,12 @@ describe('AwsDynamoDbAdapter', () => {
})
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}
const created = await adapter.items.putItem('orders', {accountId: 'acct-1', sequence: input})
const listed = await adapter.items.listItems('orders')
const key = {accountId: 'acct-1', sequence: String(normalized)}
expect(created).toEqual({id: JSON.stringify(key), key, document: key})
expect(listed).toEqual([created])
expect(created).toEqual({id: JSON.stringify(key), collectionId: null, timestamp: null, body: {accountId: 'acct-1', sequence: normalized}, metadata: {key}})
expect(listed.items).toEqual([created])
})
test('preserves safe integers, fractions, and quoted large values in nested records', async () => {
@@ -447,13 +577,13 @@ describe('AwsDynamoDbAdapter', () => {
details: {count: '9007199254740993', active: true, optional: null},
}
const result = await new AwsDynamoDbAdapter(client).putNoSqlItem('orders', document)
const result = await new AwsDynamoDbAdapter(client).items.putItem('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)
expect(result.body).toEqual(document)
})
test('returns the AWS DynamoDB schema', () => {
@@ -1,9 +1,11 @@
import {
CreateTableCommand,
DeleteItemCommand,
DeleteTableCommand,
DescribeTableCommand,
ListTablesCommand,
PutItemCommand,
QueryCommand,
ScanCommand,
type AttributeDefinition,
type AttributeValue,
@@ -13,6 +15,7 @@ import {
type TableDescription,
} from '@aws-sdk/client-dynamodb'
import {marshall, unmarshall} from '@aws-sdk/util-dynamodb'
import {DEFAULT_PAGE_LIMIT, type ChildItem, type CollectionPage, type ItemStoreAdapter, type PageQuery} from '../cloud-spi/childCollections'
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import {dynamodb as defaultDynamoDb} from '../aws'
import {awsDynamoDbSchema} from '../cloud-spi/dynamodbSchema'
@@ -20,19 +23,29 @@ import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
NoSqlItem,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
const DESCRIBE_BATCH_SIZE = 10
const ITEM_SCAN_LIMIT = 100
interface DynamoDbItemStore extends ItemStoreAdapter {
putItem(resourceId: string, body: Record<string, unknown>): Promise<ChildItem>
deleteItem(resourceId: string, itemId: string): Promise<void>
}
export class AwsDynamoDbAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'nosql' as const
readonly items: DynamoDbItemStore
constructor(private readonly dynamodb: DynamoDBClient = defaultDynamoDb) {}
constructor(private readonly dynamodb: DynamoDBClient = defaultDynamoDb) {
this.items = {
listItems: (resourceId, page) => this.listItems(resourceId, page),
queryItems: (resourceId, query, page) => this.queryItems(resourceId, query, page),
putItem: (resourceId, document) => this.putItem(resourceId, document),
deleteItem: (resourceId, itemId) => this.deleteItem(resourceId, itemId),
}
}
schema(): ServiceSchema {
return awsDynamoDbSchema()
@@ -104,31 +117,76 @@ export class AwsDynamoDbAdapter implements CloudServiceAdapter {
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
private async listItems(resourceId: string, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
return this.scanItems(resourceId, {}, page)
}
async putNoSqlItem(resourceId: string, document: Record<string, unknown>): Promise<NoSqlItem> {
private async queryItems(resourceId: string, rawQuery: string | Record<string, unknown>, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
if (!rawQuery || typeof rawQuery !== 'object' || Array.isArray(rawQuery)) {
throw new ValidationError('DynamoDB item query must be an object.')
}
const query = rawQuery as DynamoItemQuery
if (query.operation === 'scan') return this.scanItems(resourceId, query, page)
if (query.operation === 'query') return this.queryTableItems(resourceId, query, page)
throw new ValidationError('operation must be scan or query.')
}
private async scanItems(resourceId: string, query: DynamoItemQuery, page: PageQuery): Promise<CollectionPage<ChildItem>> {
const table = await this.describeTable(resourceId)
const keyNames = keyAttributes(table).map(({name}) => name)
const indexName = optionalString(query.indexName)
indexKeyAttributes(table, indexName)
const filter = filterExpression(query.filter)
const response = await this.dynamodb.send(new ScanCommand({
TableName: resourceId,
...(indexName ? {IndexName: indexName} : {}),
ExclusiveStartKey: decodeCursor(page.cursor, resourceId, indexName),
Limit: page.limit ?? DEFAULT_PAGE_LIMIT,
ReturnConsumedCapacity: 'TOTAL',
...filter,
}))
return itemPage(response, keyNames, resourceId, indexName)
}
private async queryTableItems(resourceId: string, query: DynamoItemQuery, page: PageQuery): Promise<CollectionPage<ChildItem>> {
const table = await this.describeTable(resourceId)
const primaryKeyNames = keyAttributes(table).map(({name}) => name)
const indexName = optionalString(query.indexName)
const keys = indexKeyAttributes(table, indexName)
const partitionKey = keys.find(({keyType}) => keyType === 'HASH')
const sortKey = keys.find(({keyType}) => keyType === 'RANGE')
if (!partitionKey) throw new RuntimeError(`DynamoDB table ${resourceId} has no partition key`)
if (typeof query.partitionKeyValue !== 'string' || query.partitionKeyValue.length === 0) {
throw new ValidationError(`Partition key ${partitionKey.name} is required.`)
}
const names: Record<string, string> = {'#pk': partitionKey.name}
const values: Record<string, AttributeValue> = {':pk': marshalKey(partitionKey, query.partitionKeyValue)}
let keyConditionExpression = '#pk = :pk'
if (query.sortKeyCondition !== undefined) {
if (!sortKey) throw new ValidationError('The selected table or index does not have a sort key.')
const condition = keyCondition(query.sortKeyCondition, sortKey)
names['#sk'] = sortKey.name
Object.assign(values, condition.values)
keyConditionExpression += ` AND ${condition.expression}`
}
const filter = filterExpression(query.filter, names, values)
const response = await this.dynamodb.send(new QueryCommand({
TableName: resourceId,
...(indexName ? {IndexName: indexName} : {}),
KeyConditionExpression: keyConditionExpression,
ExpressionAttributeNames: names,
ExpressionAttributeValues: values,
ExclusiveStartKey: decodeCursor(page.cursor, resourceId, indexName),
Limit: page.limit ?? DEFAULT_PAGE_LIMIT,
ReturnConsumedCapacity: 'TOTAL',
...filter,
}))
return itemPage(response, primaryKeyNames, resourceId, indexName)
}
private async putItem(resourceId: string, document: Record<string, unknown>): Promise<ChildItem> {
if (!document || typeof document !== 'object' || Array.isArray(document)) {
throw new ValidationError('Item must be a JSON object.')
}
@@ -139,12 +197,21 @@ export class AwsDynamoDbAdapter implements CloudServiceAdapter {
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)
const item = attributeValueDocument(document) ?? marshalDocument(document, keys)
validateTypedKeys(item, 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}
const key = itemKeyDocument(item, keys.map(({name}) => name))
return toItem(JSON.stringify(key), key, normalizedDocument)
}
private async deleteItem(resourceId: string, itemId: string): Promise<void> {
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 key = keyFromItemId(itemId, keys)
await this.dynamodb.send(new DeleteItemCommand({TableName: resourceId, Key: key}))
}
private async listTableNames(): Promise<string[]> {
@@ -173,9 +240,82 @@ export class AwsDynamoDbAdapter implements CloudServiceAdapter {
}
}
function toItem(id: string, key: Record<string, unknown>, body: Record<string, unknown>): ChildItem {
return {id, collectionId: null, timestamp: null, body, metadata: {key}}
}
function keyFromItemId(itemId: string, keys: KeyAttribute[]): Record<string, AttributeValue> {
let key: unknown
try {
key = JSON.parse(itemId)
} catch {
throw new ValidationError('Item identifier must be a DynamoDB key JSON object.')
}
if (!key || typeof key !== 'object' || Array.isArray(key)) {
throw new ValidationError('Item identifier must be a DynamoDB key JSON object.')
}
const document = key as Record<string, unknown>
return Object.fromEntries(keys.map((keyAttribute) => {
if (!Object.prototype.hasOwnProperty.call(document, keyAttribute.name)) {
throw new ValidationError(`Key attribute ${keyAttribute.name} is required.`)
}
return [keyAttribute.name, marshalKey(keyAttribute, document[keyAttribute.name])]
}))
}
function attributeValueDocument(document: Record<string, unknown>): Record<string, AttributeValue> | undefined {
const entries = Object.entries(document)
if (entries.length === 0 || !entries.every(([, value]) => isAttributeValueJson(value))) return undefined
return Object.fromEntries(entries.map(([name, value]) => [name, fromAttributeValueJson(value as Record<string, unknown>)]))
}
function isAttributeValueJson(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
const entries = Object.entries(value)
if (entries.length !== 1) return false
const [type, raw] = entries[0]
if (type === 'S') return typeof raw === 'string'
if (type === 'N') return typeof raw === 'string' && isDynamoNumber(raw)
if (type === 'B') return typeof raw === 'string' && isBase64(raw)
if (type === 'BOOL') return typeof raw === 'boolean'
if (type === 'NULL') return raw === true
if (type === 'SS') return Array.isArray(raw) && raw.length > 0 && raw.every((item) => typeof item === 'string')
if (type === 'NS') return Array.isArray(raw) && raw.length > 0 && raw.every((item) => typeof item === 'string' && isDynamoNumber(item))
if (type === 'BS') return Array.isArray(raw) && raw.length > 0 && raw.every((item) => typeof item === 'string' && isBase64(item))
if (type === 'L') return Array.isArray(raw) && raw.every(isAttributeValueJson)
if (type === 'M') return Boolean(raw) && typeof raw === 'object' && !Array.isArray(raw) && Object.values(raw).every(isAttributeValueJson)
return false
}
function fromAttributeValueJson(value: Record<string, unknown>): AttributeValue {
const [type, raw] = Object.entries(value)[0]
if (type === 'B') return {B: Buffer.from(raw as string, 'base64')}
if (type === 'BS') return {BS: (raw as string[]).map((item) => Buffer.from(item, 'base64'))}
if (type === 'L') return {L: (raw as Record<string, unknown>[]).map(fromAttributeValueJson)}
if (type === 'M') return {M: Object.fromEntries(Object.entries(raw as Record<string, Record<string, unknown>>).map(([name, child]) => [name, fromAttributeValueJson(child)]))}
if (type === 'S') return {S: raw as string}
if (type === 'N') return {N: raw as string}
if (type === 'BOOL') return {BOOL: raw as boolean}
if (type === 'NULL') return {NULL: true}
if (type === 'SS') return {SS: raw as string[]}
if (type === 'NS') return {NS: raw as string[]}
throw new ValidationError('Item contains an invalid DynamoDB AttributeValue.')
}
function validateTypedKeys(item: Record<string, AttributeValue>, keys: KeyAttribute[]): void {
for (const key of keys) {
if (!Object.prototype.hasOwnProperty.call(item, key.name)) throw new ValidationError(`Key attribute ${key.name} is required.`)
const value = item[key.name]
if (key.type === 'N' && typeof value.N !== 'string') throw new ValidationError(`Key attribute ${key.name} must be a number.`)
if (key.type === 'B' && !value.B) throw new ValidationError(`Binary key attribute ${key.name} must be a non-empty base64 string.`)
if (key.type === 'S' && !value.S) throw new ValidationError(`Key attribute ${key.name} must be a non-empty string.`)
}
}
interface KeyAttribute {
name: string
type?: ScalarAttributeType
keyType?: 'HASH' | 'RANGE'
}
function keyAttributes(table: TableDescription): KeyAttribute[] {
@@ -186,7 +326,181 @@ function keyAttributes(table: TableDescription): KeyAttribute[] {
)
return (table.KeySchema ?? [])
.filter((key): key is KeySchemaElement & {AttributeName: string} => Boolean(key.AttributeName))
.map((key) => ({name: key.AttributeName, type: attributeTypes.get(key.AttributeName)}))
.map((key) => ({name: key.AttributeName, type: attributeTypes.get(key.AttributeName), keyType: key.KeyType}))
}
interface DynamoItemQuery {
operation?: unknown
indexName?: unknown
partitionKeyValue?: unknown
sortKeyCondition?: unknown
filter?: unknown
}
interface DynamoCondition {
operator?: unknown
value?: unknown
secondValue?: unknown
}
interface DynamoFilter extends DynamoCondition {
attribute?: unknown
type?: unknown
}
const COMPARISON_OPERATORS = new Map([
['EQ', '='], ['NE', '<>'], ['LT', '<'], ['LE', '<='], ['GT', '>'], ['GE', '>='],
])
function indexKeyAttributes(table: TableDescription, indexName?: string): KeyAttribute[] {
if (!indexName) return keyAttributes(table)
const indexes = [...(table.GlobalSecondaryIndexes ?? []), ...(table.LocalSecondaryIndexes ?? [])]
const index = indexes.find((candidate) => candidate.IndexName === indexName)
if (!index) throw new ValidationError(`DynamoDB index ${indexName} does not exist.`)
return keyAttributes({...table, KeySchema: index.KeySchema})
}
function keyCondition(raw: unknown, sortKey: KeyAttribute): {expression: string; values: Record<string, AttributeValue>} {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new ValidationError('sortKeyCondition must be an object.')
const condition = raw as DynamoCondition
const operator = typeof condition.operator === 'string' ? condition.operator : ''
const value = requiredConditionValue(condition.value, 'Sort key value')
const values: Record<string, AttributeValue> = {':sk': marshalKey(sortKey, value)}
const comparison = COMPARISON_OPERATORS.get(operator)
if (comparison) return {expression: `#sk ${comparison} :sk`, values}
if (operator === 'begins_with') return {expression: 'begins_with(#sk, :sk)', values}
if (operator === 'BETWEEN') {
const secondValue = requiredConditionValue(condition.secondValue, 'Second sort key value')
values[':sk2'] = marshalKey(sortKey, secondValue)
return {expression: '#sk BETWEEN :sk AND :sk2', values}
}
throw new ValidationError('Sort key operator must be EQ, LT, LE, GT, GE, BETWEEN, or begins_with.')
}
function filterExpression(
raw: unknown,
names: Record<string, string> = {},
values: Record<string, AttributeValue> = {},
): {FilterExpression?: string; ExpressionAttributeNames?: Record<string, string>; ExpressionAttributeValues?: Record<string, AttributeValue>} {
if (raw === undefined || raw === null) return {}
if (typeof raw !== 'object' || Array.isArray(raw)) throw new ValidationError('filter must be an object.')
const filter = raw as DynamoFilter
const attribute = requiredConditionValue(filter.attribute, 'Filter attribute')
const operator = typeof filter.operator === 'string' ? filter.operator : ''
names['#filter'] = attribute
if (operator === 'attribute_exists' || operator === 'attribute_not_exists') {
return {FilterExpression: `${operator}(#filter)`, ExpressionAttributeNames: names, ...(Object.keys(values).length ? {ExpressionAttributeValues: values} : {})}
}
values[':filter'] = typedAttributeValue(filter.type, filter.value, 'Filter value')
const comparison = COMPARISON_OPERATORS.get(operator)
let expression: string
if (comparison) expression = `#filter ${comparison} :filter`
else if (operator === 'begins_with' || operator === 'contains') expression = `${operator}(#filter, :filter)`
else if (operator === 'BETWEEN') {
values[':filter2'] = typedAttributeValue(filter.type, filter.secondValue, 'Second filter value')
expression = '#filter BETWEEN :filter AND :filter2'
} else {
throw new ValidationError('Filter operator is not supported.')
}
return {FilterExpression: expression, ExpressionAttributeNames: names, ExpressionAttributeValues: values}
}
function typedAttributeValue(type: unknown, raw: unknown, field: string): AttributeValue {
const value = requiredConditionValue(raw, field)
if (type === 'S') return {S: value}
if (type === 'N') {
if (!isDynamoNumber(value)) throw new ValidationError(`${field} must be a number.`)
return {N: value}
}
if (type === 'B') {
if (!isBase64(value)) throw new ValidationError(`${field} must be non-empty base64.`)
return {B: Buffer.from(value, 'base64')}
}
if (type === 'BOOL') {
if (value !== 'true' && value !== 'false') throw new ValidationError(`${field} must be true or false.`)
return {BOOL: value === 'true'}
}
throw new ValidationError('Filter type must be S, N, B, or BOOL.')
}
function requiredConditionValue(value: unknown, field: string): string {
if (typeof value !== 'string' || value.length === 0) throw new ValidationError(`${field} is required.`)
return value
}
function optionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined
}
function itemPage(
response: {Items?: Record<string, AttributeValue>[]; LastEvaluatedKey?: Record<string, AttributeValue>; Count?: number; ScannedCount?: number; ConsumedCapacity?: {CapacityUnits?: number}},
keyNames: string[],
tableName: string,
indexName?: string,
): CollectionPage<ChildItem> {
const items = (response.Items ?? []).map((rawItem) => {
const document = normalizeDocument(unmarshall(rawItem, {wrapNumbers: normalizeNumber}))
const key = itemKeyDocument(rawItem, keyNames)
return toItem(JSON.stringify(key), key, document)
})
return {
items,
nextCursor: response.LastEvaluatedKey && Object.keys(response.LastEvaluatedKey).length
? encodeCursor(response.LastEvaluatedKey, tableName, indexName)
: null,
count: response.Count ?? items.length,
...(response.ScannedCount === undefined ? {} : {scannedCount: response.ScannedCount}),
...(response.ConsumedCapacity?.CapacityUnits === undefined ? {} : {consumedCapacity: response.ConsumedCapacity.CapacityUnits}),
}
}
function itemKeyDocument(item: Record<string, AttributeValue>, keyNames: string[]): Record<string, unknown> {
return Object.fromEntries(keyNames.map((name) => {
const value = item[name]
if (!value) throw new RuntimeError(`DynamoDB item is missing key attribute ${name}`)
if (value.S !== undefined) return [name, value.S]
if (value.N !== undefined) return [name, String(normalizeNumber(value.N))]
if (value.B !== undefined) return [name, Buffer.from(value.B).toString('base64')]
throw new RuntimeError(`DynamoDB key attribute ${name} has an unsupported type`)
}))
}
function encodeCursor(key: Record<string, AttributeValue>, tableName: string, indexName?: string): string {
const payload = {v: 1, tableName, indexName: indexName ?? null, key: Object.fromEntries(Object.entries(key).map(([name, value]) => [name, toAttributeValueJson(value)]))}
return Buffer.from(JSON.stringify(payload)).toString('base64url')
}
function decodeCursor(cursor: string | undefined, tableName: string, indexName?: string): Record<string, AttributeValue> | undefined {
if (!cursor) return undefined
try {
const decoded = Buffer.from(cursor, 'base64url')
if (decoded.toString('base64url') !== cursor) throw new Error('non-canonical cursor')
const payload = JSON.parse(decoded.toString()) as {v?: unknown; tableName?: unknown; indexName?: unknown; key?: unknown}
if (payload.v !== 1 || payload.tableName !== tableName || payload.indexName !== (indexName ?? null) || !payload.key || typeof payload.key !== 'object' || Array.isArray(payload.key)) {
throw new Error('cursor context mismatch')
}
const entries = Object.entries(payload.key)
if (!entries.length || !entries.every(([, value]) => isAttributeValueJson(value))) throw new Error('invalid cursor key')
return Object.fromEntries(entries.map(([name, value]) => [name, fromAttributeValueJson(value as Record<string, unknown>)]))
} catch (error) {
throw new ValidationError('cursor is not valid for this DynamoDB table and index.', {cause: error})
}
}
function toAttributeValueJson(value: AttributeValue): Record<string, unknown> {
if ('B' in value && value.B !== undefined) return {B: Buffer.from(value.B).toString('base64')}
if ('BS' in value && value.BS !== undefined) return {BS: value.BS.map((item) => Buffer.from(item).toString('base64'))}
if ('L' in value && value.L !== undefined) return {L: value.L.map(toAttributeValueJson)}
if ('M' in value && value.M !== undefined) return {M: Object.fromEntries(Object.entries(value.M).map(([name, child]) => [name, toAttributeValueJson(child)]))}
if ('S' in value) return {S: value.S}
if ('N' in value) return {N: value.N}
if ('BOOL' in value) return {BOOL: value.BOOL}
if ('NULL' in value) return {NULL: value.NULL}
if ('SS' in value) return {SS: value.SS}
if ('NS' in value) return {NS: value.NS}
throw new ValidationError('DynamoDB returned an unsupported pagination key.')
}
function marshalDocument(document: Record<string, unknown>, keys: KeyAttribute[]): Record<string, AttributeValue> {
@@ -8,6 +8,7 @@ import {
ListRulesCommand,
ListTargetsByRuleCommand,
PutRuleCommand,
PutEventsCommand,
PutTargetsCommand,
RemoveTargetsCommand,
type EventBridgeClient,
@@ -120,6 +121,16 @@ describe('AwsEventBridgeAdapter', () => {
expect((commands[2] as AddPermissionCommand).input).toMatchObject({FunctionName: 'arn:aws:lambda:us-east-1:000000000000:function:worker', Principal: 'events.amazonaws.com', SourceArn: 'arn:aws:events:us-east-1:000000000000:rule/orders/hourly'})
})
test('publishes a validated event to the selected bus', async () => {
const adapter = createAdapter(async (command) => {
expect(command).toBeInstanceOf(PutEventsCommand)
expect((command as PutEventsCommand).input).toEqual({Entries: [{EventBusName: 'orders', Source: 'orders', DetailType: 'order.created', Detail: '{"id":"123"}'}]})
return {Entries: [{EventId: 'event-123'}]}
})
await expect(adapter.putEventBridgeEvent('event-bus:orders', {source: 'orders', detailType: 'order.created', detail: '{"id":"123"}'})).resolves.toEqual({eventId: 'event-123', errorCode: null, errorMessage: null})
})
test('removes targets before deleting a rule', async () => {
const commands: unknown[] = []
const adapter = createAdapter(async (command) => {
@@ -7,6 +7,7 @@ import {
ListRulesCommand,
ListTargetsByRuleCommand,
PutRuleCommand,
PutEventsCommand,
PutTargetsCommand,
RemoveTargetsCommand,
type EventBridgeClient,
@@ -25,6 +26,8 @@ import type {
EventBridgeRuleInput,
EventBridgeRuleOverview,
EventBridgeRuleTarget,
EventBridgePutEventInput,
EventBridgePutEventResult,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
@@ -152,6 +155,24 @@ export class AwsEventBridgeAdapter implements CloudServiceAdapter {
await this.eventbridge.send(new DeleteRuleCommand({Name: name, ...eventBusInput(busName)}))
}
async putEventBridgeEvent(eventBusId: string, input: EventBridgePutEventInput): Promise<EventBridgePutEventResult> {
const source = requiredValue(input.source, 'source')
const detailType = requiredValue(input.detailType, 'detailType')
const detail = requiredValue(input.detail, 'detail')
try {
const parsed: unknown = JSON.parse(detail)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object')
} catch {
throw new ValidationError('detail must be a JSON object')
}
const response = await this.eventbridge.send(new PutEventsCommand({Entries: [{
EventBusName: eventBusName(eventBusId), Source: source, DetailType: detailType, Detail: detail,
...(input.resources?.length ? {Resources: input.resources} : {}),
}]}))
const entry = response.Entries?.[0]
return {eventId: entry?.EventId ?? null, errorCode: entry?.ErrorCode ?? null, errorMessage: entry?.ErrorMessage ?? null}
}
private async listTargets(ruleName: string, busName: string): Promise<Target[]> {
const targets: Target[] = []
let nextToken: string | undefined
@@ -215,6 +215,42 @@ describe('AwsLogsAdapter queryLogs', () => {
})
})
describe('AwsLogsAdapter filterLogEvents', () => {
test('filters across streams and preserves event identity and pagination', async () => {
const {client, calls} = stubClient({
FilterLogEventsCommand: () => ({
events: [{eventId: 'event-1', logStreamName: 'stream-a', timestamp: 1785260000000, message: 'ERROR failed', ingestionTime: 1785260001000}],
nextToken: 'page-2',
}),
})
const page = await new AwsLogsAdapter(client).filterLogEvents('/floci/probe', {
filterPattern: 'ERROR',
logStreamNames: ['stream-a'],
startTime: 1000,
endTime: 2000,
}, {cursor: 'page-1', limit: 25})
expect(calls[0]).toMatchObject({command: 'FilterLogEventsCommand', input: {
logGroupName: '/floci/probe', filterPattern: 'ERROR', logStreamNames: ['stream-a'],
startTime: 1000, endTime: 2000, nextToken: 'page-1', limit: 25,
}})
expect(page).toEqual({
items: [{
id: 'event-1', collectionId: 'stream-a', timestamp: new Date(1785260000000).toISOString(),
body: {message: 'ERROR failed'}, metadata: {ingestionTime: new Date(1785260001000).toISOString()},
}],
nextCursor: 'page-2',
})
})
test('retains a continuation token on an empty page', async () => {
const {client} = stubClient({FilterLogEventsCommand: () => ({events: [], nextToken: 'keep-going'})})
await expect(new AwsLogsAdapter(client).filterLogEvents('/floci/probe', {}))
.resolves.toEqual({items: [], nextCursor: 'keep-going'})
})
})
describe('AwsLogsAdapter documents', () => {
test('lists log streams as collections, newest activity first', async () => {
const {client, calls} = stubClient({
@@ -6,6 +6,7 @@ import {
DeleteLogStreamCommand,
DescribeLogGroupsCommand,
DescribeLogStreamsCommand,
FilterLogEventsCommand,
GetLogEventsCommand,
GetQueryResultsCommand,
StartQueryCommand,
@@ -25,6 +26,7 @@ import type {
CreateResourceInput,
LogsInsightsQueryInput,
LogsInsightsQueryResult,
LogsFilterInput,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
@@ -150,6 +152,30 @@ export class AwsLogsAdapter implements CloudServiceAdapter {
}
}
async filterLogEvents(logGroupName: string, input: LogsFilterInput, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
const response = await this.client.send(new FilterLogEventsCommand({
logGroupName,
filterPattern: input.filterPattern || undefined,
logStreamNames: input.logStreamNames?.length ? input.logStreamNames : undefined,
startTime: input.startTime,
endTime: input.endTime,
limit: page.limit,
nextToken: page.cursor,
}))
return {
items: (response.events ?? []).map((event, index) => ({
id: event.eventId ?? `${event.logStreamName ?? ''}-${event.timestamp ?? 0}-${index}`,
collectionId: event.logStreamName ?? null,
timestamp: event.timestamp ? new Date(event.timestamp).toISOString() : null,
body: {message: event.message ?? ''},
metadata: {
ingestionTime: event.ingestionTime ? new Date(event.ingestionTime).toISOString() : null,
},
})),
nextCursor: response.nextToken ?? null,
}
}
/**
* DescribeLogGroups filters by prefix, so `/floci/probe` also returns
* `/floci/probe-two`. Filtering to an exact match keeps `get` honest.
@@ -1,9 +1,15 @@
import {
CreateQueueCommand,
DeleteMessageCommand,
DeleteQueueCommand,
GetQueueAttributesCommand,
GetQueueUrlCommand,
ListQueuesCommand,
PurgeQueueCommand,
ReceiveMessageCommand,
SendMessageCommand,
SetQueueAttributesCommand,
StartMessageMoveTaskCommand,
type SQSClient,
} from '@aws-sdk/client-sqs'
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
@@ -15,6 +21,12 @@ import type {
CreateResourceInput,
ResourceQuery,
ServiceSchema,
SqsMessage,
SqsMoveTaskInput,
SqsMoveTaskResult,
SqsReceiveMessagesInput,
SqsSendMessageInput,
UpdateResourceInput,
} from '../cloud-spi/types'
/**
@@ -38,6 +50,7 @@ interface QueueAttributes {
CreatedTimestamp?: string
LastModifiedTimestamp?: string
FifoQueue?: string
RedrivePolicy?: string
}
export class AwsSqsAdapter implements CloudServiceAdapter {
@@ -111,6 +124,69 @@ export class AwsSqsAdapter implements CloudServiceAdapter {
await this.sqs.send(new DeleteQueueCommand({QueueUrl: url}))
}
async update(id: string, input: UpdateResourceInput): Promise<CloudResource> {
const url = await this.requireQueueUrl(id)
const attributes: Record<string, string> = {}
const visibility = numericAttribute(input.values.visibilityTimeout, 'visibilityTimeout', 0, 43_200)
const retention = numericAttribute(input.values.messageRetentionPeriod, 'messageRetentionPeriod', 60, 1_209_600)
if (visibility) attributes.VisibilityTimeout = visibility
if (retention) attributes.MessageRetentionPeriod = retention
const redrive = input.values.redrivePolicy
if (redrive !== undefined) {
if (!redrive || typeof redrive !== 'object' || Array.isArray(redrive)) throw new ValidationError('redrivePolicy must be an object.')
const policy = redrive as {deadLetterTargetArn?: unknown; maxReceiveCount?: unknown}
if (typeof policy.deadLetterTargetArn !== 'string' || !policy.deadLetterTargetArn) throw new ValidationError('A dead-letter queue is required.')
const count = numericAttribute(policy.maxReceiveCount, 'maxReceiveCount', 1, 1_000)
if (!count) throw new ValidationError('maxReceiveCount is required.')
attributes.RedrivePolicy = JSON.stringify({deadLetterTargetArn: policy.deadLetterTargetArn, maxReceiveCount: count})
}
if (Object.keys(attributes).length === 0) throw new ValidationError('No SQS queue settings were supplied.')
await this.sqs.send(new SetQueueAttributesCommand({QueueUrl: url, Attributes: attributes}))
return (await this.get(id))!
}
async sendSqsMessage(queueId: string, input: SqsSendMessageInput): Promise<{messageId: string}> {
const url = await this.requireQueueUrl(queueId)
if (!input.messageBody) throw new ValidationError('messageBody is required.')
const queue = await this.get(queueId)
if (queue?.type === 'fifo-queue' && !input.messageGroupId) throw new ValidationError('messageGroupId is required for FIFO queues.')
const messageAttributes = Object.fromEntries(Object.entries(input.messageAttributes ?? {}).map(([name, value]) => [name, {
DataType: value.dataType,
...(value.stringValue !== undefined ? {StringValue: value.stringValue} : {}),
...(value.binaryValue !== undefined ? {BinaryValue: Buffer.from(value.binaryValue, 'base64')} : {}),
}]))
const response = await this.sqs.send(new SendMessageCommand({QueueUrl: url, MessageBody: input.messageBody, MessageAttributes: messageAttributes, MessageGroupId: input.messageGroupId, MessageDeduplicationId: input.messageDeduplicationId}))
return {messageId: response.MessageId ?? ''}
}
async receiveSqsMessages(queueId: string, input: SqsReceiveMessagesInput): Promise<SqsMessage[]> {
const url = await this.requireQueueUrl(queueId)
const max = input.maxNumberOfMessages ?? 1
if (!Number.isInteger(max) || max < 1 || max > 10) throw new ValidationError('maxNumberOfMessages must be between 1 and 10.')
const response = await this.sqs.send(new ReceiveMessageCommand({QueueUrl: url, MaxNumberOfMessages: max, MessageSystemAttributeNames: ['All'], MessageAttributeNames: ['All']}))
return (response.Messages ?? []).map((message) => ({
messageId: message.MessageId ?? '', receiptHandle: message.ReceiptHandle ?? '', body: message.Body ?? '',
attributes: message.Attributes ?? {},
messageAttributes: Object.fromEntries(Object.entries(message.MessageAttributes ?? {}).map(([name, value]) => [name, {dataType: value.DataType ?? '', stringValue: value.StringValue, binaryValue: value.BinaryValue ? Buffer.from(value.BinaryValue).toString('base64') : undefined}])),
}))
}
async deleteSqsMessage(queueId: string, receiptHandle: string): Promise<void> {
await this.sqs.send(new DeleteMessageCommand({QueueUrl: await this.requireQueueUrl(queueId), ReceiptHandle: receiptHandle}))
}
async purgeSqsQueue(queueId: string): Promise<void> {
await this.sqs.send(new PurgeQueueCommand({QueueUrl: await this.requireQueueUrl(queueId)}))
}
async startSqsMoveTask(queueId: string, input: SqsMoveTaskInput): Promise<SqsMoveTaskResult> {
const queue = await this.get(queueId)
const sourceArn = typeof queue?.metadata.arn === 'string' ? queue.metadata.arn : undefined
if (!sourceArn) throw new NotFoundError(`Queue ${queueId} does not exist`)
const response = await this.sqs.send(new StartMessageMoveTaskCommand({SourceArn: sourceArn, DestinationArn: input.destinationArn}))
return {taskHandle: response.TaskHandle ?? ''}
}
/** Listing queue names is cheaper than describing each one. */
async health(): Promise<void> {
await this.sqs.send(new ListQueuesCommand({}))
@@ -126,6 +202,12 @@ export class AwsSqsAdapter implements CloudServiceAdapter {
}
}
private async requireQueueUrl(name: string): Promise<string> {
const url = await this.queueUrl(name)
if (!url) throw new NotFoundError(`Queue ${name} does not exist`)
return url
}
private async attributesFor(url: string): Promise<QueueAttributes> {
if (!url) return {}
const res = await this.sqs.send(
@@ -157,10 +239,21 @@ function toResource(name: string, url: string, attributes: QueueAttributes): Clo
maximumMessageSize: numberValue(attributes.MaximumMessageSize),
delaySeconds: numberValue(attributes.DelaySeconds),
lastModified: epochSecondsToIso(attributes.LastModifiedTimestamp),
redrivePolicy: parseRedrivePolicy(attributes.RedrivePolicy),
},
}
}
function parseRedrivePolicy(value: string | undefined): {deadLetterTargetArn?: string; maxReceiveCount?: number} | null {
if (!value) return null
try {
const policy = JSON.parse(value) as {deadLetterTargetArn?: unknown; maxReceiveCount?: unknown}
return {deadLetterTargetArn: typeof policy.deadLetterTargetArn === 'string' ? policy.deadLetterTargetArn : undefined, maxReceiveCount: typeof policy.maxReceiveCount === 'string' ? Number(policy.maxReceiveCount) : undefined}
} catch {
return null
}
}
/** SQS reports timestamps as epoch seconds; the contract uses ISO strings. */
function epochSecondsToIso(value: string | undefined): string | null {
if (!value) return null
+10 -2
View File
@@ -23,6 +23,12 @@ export interface CollectionPage<T> {
* round-trip a cursor to a different resource or collection.
*/
nextCursor: string | null
/** Provider-reported result count for this page. */
count?: number
/** Provider-reported number of records examined before filtering. */
scannedCount?: number
/** Provider-reported capacity consumed by this request. */
consumedCapacity?: number
}
export interface PageQuery {
@@ -30,6 +36,8 @@ export interface PageQuery {
limit?: number
}
export type ItemQuery = string | Record<string, unknown>
/** The middle level: a log stream, a Cosmos container, a BigQuery table. */
export interface ChildCollection {
id: string
@@ -61,7 +69,7 @@ export interface DocumentStoreAdapter {
getItem?(resourceId: string, collectionId: string, itemId: string): Promise<ChildItem | null>
putItem?(resourceId: string, collectionId: string, body: Record<string, unknown>): Promise<ChildItem>
deleteItem?(resourceId: string, collectionId: string, itemId: string, partitionKey?: string | null): Promise<void>
queryItems?(resourceId: string, collectionId: string, query: string): Promise<CollectionPage<ChildItem>>
queryItems?(resourceId: string, collectionId: string, query: ItemQuery, page?: PageQuery): Promise<CollectionPage<ChildItem>>
}
/** Flat store: the resource is the collection. */
@@ -70,7 +78,7 @@ export interface ItemStoreAdapter {
getItem?(resourceId: string, itemId: string): Promise<ChildItem | null>
putItem?(resourceId: string, body: Record<string, unknown>): Promise<ChildItem>
deleteItem?(resourceId: string, itemId: string, partitionKey?: string | null): Promise<void>
queryItems?(resourceId: string, query: string): Promise<CollectionPage<ChildItem>>
queryItems?(resourceId: string, query: ItemQuery, page?: PageQuery): Promise<CollectionPage<ChildItem>>
}
/**
@@ -13,6 +13,7 @@ const cloudformationFilters: FieldSchema[] = [
const cloudformationResourceActions: CapabilitySchema<ResourceActionName>[] = [
{name: 'list', label: 'List stacks', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create stack', enabled: true, status: 'available', runtimeRequired: true},
{name: 'update', label: 'Update stack', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete stack', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect stack', enabled: true, status: 'available', runtimeRequired: false},
]
@@ -38,18 +39,19 @@ export function awsCloudformationSchema(): ServiceSchema {
{
name: 'templateBody',
label: 'Template Body',
type: 'text',
type: 'textarea',
required: true,
span: true,
description: 'Single-line CloudFormation template JSON — the console form has no multi-line editor yet.',
description: 'CloudFormation JSON or YAML template.',
},
],
actions: ['list', 'create', 'delete', 'inspect'],
actions: ['list', 'create', 'update', 'delete', 'inspect'],
capabilities: {
resourceActions: cloudformationResourceActions,
},
filters: cloudformationFilters,
columns: cloudformationColumns,
updateFields: [{name: 'templateBody', label: 'Template Body', type: 'textarea', required: true, span: true, valuePath: 'metadata.templateBody'}],
}
}
@@ -83,6 +83,12 @@ export function awsDynamoDbSchema(): ServiceSchema {
{name: 'delete', label: 'Delete table', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect table', enabled: true, status: 'available', runtimeRequired: true},
],
itemActions: [
{name: 'list', label: 'List items', enabled: true, status: 'available', runtimeRequired: true},
{name: 'query', label: 'Scan or query items', enabled: true, status: 'available', runtimeRequired: true},
{name: 'put', label: 'Put item', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete item', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: dynamodbFilters,
columns: dynamodbColumns,
+81 -6
View File
@@ -2,10 +2,13 @@
// This type-only cycle with serviceCatalog.ts is erased at compile time.
import type {CloudServiceType, ServiceGroup} from './serviceCatalog'
import type {
ChildItem,
CollectionActionName,
CollectionPage,
DocumentStoreAdapter,
ItemActionName,
ItemStoreAdapter,
PageQuery,
} from './childCollections'
export type {CloudServiceType, ServiceGroup}
@@ -321,10 +324,45 @@ export interface LogsInsightsQueryResult {
rows: Array<Record<string, string>>
}
export interface NoSqlItem {
id: string
key: Record<string, unknown>
document: Record<string, unknown>
/** Mirrors FilterLogEvents: epoch milliseconds and optional cross-stream filtering. */
export interface LogsFilterInput {
filterPattern?: string
logStreamNames?: string[]
startTime?: number
endTime?: number
}
export interface SqsMessageAttribute {
dataType: string
stringValue?: string
binaryValue?: string
}
export interface SqsMessage {
messageId: string
receiptHandle: string
body: string
attributes: Record<string, string>
messageAttributes: Record<string, SqsMessageAttribute>
}
export interface SqsSendMessageInput {
messageBody: string
messageAttributes?: Record<string, SqsMessageAttribute>
messageGroupId?: string
messageDeduplicationId?: string
}
export interface SqsReceiveMessagesInput {
maxNumberOfMessages?: number
}
export interface SqsMoveTaskInput {
destinationArn?: string
}
export interface SqsMoveTaskResult {
taskHandle: string
}
/** Provider-neutral representation of a managed Kubernetes node pool. */
@@ -476,6 +514,8 @@ export interface SnsSubscriptionOverview {
export interface ApiGatewayLambdaRouteInput {
path: string
parentResourceId?: string
pathPart?: string
method: string
stage: string
functionName: string
@@ -530,7 +570,16 @@ export interface ApiGatewayRouteOverview {
integrationType: string | null
}
export interface ApiGatewayResourceOverview {
resourceId: string
parentId: string | null
path: string
pathPart: string | null
methods: ApiGatewayRouteOverview[]
}
export interface ApiGatewayRoutesResult {
resources: ApiGatewayResourceOverview[]
routes: ApiGatewayRouteOverview[]
stages: string[]
}
@@ -612,6 +661,22 @@ export interface EventBridgeRuleOverview extends EventBridgeRuleInput {
state: 'ENABLED' | 'DISABLED'
}
export interface EventBridgePutEventInput {
source: string
detailType: string
detail: string
resources?: string[]
}
export interface EventBridgePutEventResult {
eventId: string | null
errorCode: string | null
errorMessage: string | null
}
export interface CloudFormationChangeSetInput { name: string; templateBody: string; parameters?: Record<string, string>; capabilities?: string[] }
export interface CloudFormationChangeSet { name: string; id: string; status: string | null; executionStatus: string | null; changes: Array<{action: string | null; logicalResourceId: string | null; resourceType: string | null}> }
export interface SqsLambdaEventSourceMappingInput {
functionName: string
queueArn: string
@@ -714,6 +779,12 @@ export interface CloudServiceAdapter {
listEventBridgeRules?(eventBusId: string): Promise<EventBridgeRuleOverview[]>
createEventBridgeRule?(eventBusId: string, input: EventBridgeRuleInput): Promise<EventBridgeRuleOverview>
deleteEventBridgeRule?(eventBusId: string, ruleName: string): Promise<void>
putEventBridgeEvent?(eventBusId: string, input: EventBridgePutEventInput): Promise<EventBridgePutEventResult>
listCloudFormationChangeSets?(stackName: string): Promise<CloudFormationChangeSet[]>
createCloudFormationChangeSet?(stackName: string, input: CloudFormationChangeSetInput): Promise<CloudFormationChangeSet>
executeCloudFormationChangeSet?(stackName: string, changeSetName: string): Promise<void>
deleteCloudFormationChangeSet?(stackName: string, changeSetName: string): Promise<void>
updateCloudFormationTerminationProtection?(stackName: string, enabled: boolean): Promise<void>
createSqsLambdaEventSourceMapping?(input: SqsLambdaEventSourceMappingInput): Promise<SqsLambdaEventSourceMappingOverview>
listSqsLambdaEventSourceMappings?(functionName: string): Promise<SqsLambdaEventSourceMappingOverview[]>
deleteSqsLambdaEventSourceMapping?(functionName: string, mappingId: string): Promise<void>
@@ -751,8 +822,12 @@ export interface CloudServiceAdapter {
listSqlTables?(serverId: string, connection: SqlConnectionInput): Promise<SqlTable[]>
querySql?(serverId: string, connection: SqlConnectionInput, query: string): Promise<SqlQueryResult>
queryLogs?(logGroupName: string, input: LogsInsightsQueryInput): Promise<LogsInsightsQueryResult>
listNoSqlItems?(resourceId: string): Promise<NoSqlItem[]>
putNoSqlItem?(resourceId: string, document: Record<string, unknown>): Promise<NoSqlItem>
filterLogEvents?(logGroupName: string, input: LogsFilterInput, page?: PageQuery): Promise<CollectionPage<ChildItem>>
sendSqsMessage?(queueId: string, input: SqsSendMessageInput): Promise<{messageId: string}>
receiveSqsMessages?(queueId: string, input: SqsReceiveMessagesInput): Promise<SqsMessage[]>
deleteSqsMessage?(queueId: string, receiptHandle: string): Promise<void>
purgeSqsQueue?(queueId: string): Promise<void>
startSqsMoveTask?(queueId: string, input: SqsMoveTaskInput): Promise<SqsMoveTaskResult>
listKubernetesNodegroups?(clusterId: string): Promise<KubernetesNodegroup[]>
createKubernetesNodegroup?(clusterId: string, input: CreateKubernetesNodegroupInput): Promise<KubernetesNodegroup>
deleteKubernetesNodegroup?(clusterId: string, nodegroupId: string): Promise<void>
+137 -16
View File
@@ -35,7 +35,6 @@ import type {
CreateResourceInput,
DatabaseSnapshot,
LogsInsightsQueryResult,
NoSqlItem,
} from '../cloud-spi/types'
import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry'
import {CloudProxyService} from '../service/CloudProxyService'
@@ -197,6 +196,44 @@ describe('API Gateway method test routes', () => {
})
})
describe('API Gateway resource routes', () => {
test('dispatches parent-scoped nested route creation and returns the resource tree', async () => {
const calls: unknown[] = []
const tree = {
resources: [
{resourceId: 'root-123', parentId: null, path: '/', pathPart: null, methods: []},
{resourceId: 'orders-123', parentId: 'root-123', path: '/orders', pathPart: 'orders', methods: []},
],
routes: [],
stages: ['dev'],
}
const app = appWithRoutes([mockAdapter('aws', {
service: 'apigateway',
createApiGatewayLambdaRoute: async (apiId, input) => {
calls.push({apiId, input})
return {resourceId: 'order-123', deploymentId: 'deployment-123', invokeUrl: '/orders/{id}'}
},
listApiGatewayRoutes: async () => tree,
})])
const create = await app.request('/api/clouds/aws/services/apigateway/resources/api-123/routes', {
method: 'POST', headers: {'content-type': 'application/json'},
body: JSON.stringify({
path: '/orders/{id}', parentResourceId: 'orders-123', pathPart: '{id}',
method: 'PATCH', stage: 'dev', functionName: 'orders-handler',
}),
})
const list = await app.request('/api/clouds/aws/services/apigateway/resources/api-123/routes')
expect(create.status).toBe(201)
expect(await list.json()).toEqual(tree)
expect(calls).toEqual([{apiId: 'api-123', input: {
path: '/orders/{id}', parentResourceId: 'orders-123', pathPart: '{id}',
method: 'PATCH', stage: 'dev', functionName: 'orders-handler',
}}])
})
})
describe('S3 website routes', () => {
test('routes normalized website configuration lifecycle requests through the storage adapter', async () => {
const calls: Array<{operation: string; args: unknown[]}> = []
@@ -579,46 +616,61 @@ describe('cloud schema routes', () => {
expect(body[0].name).toBe('items')
})
test('lists DynamoDB records through the nosql adapter', async () => {
test('lists DynamoDB records through the generic item adapter', async () => {
const app = appWithRoutes([mockAdapter('aws', {
service: 'nosql',
schema: awsDynamoDbSchema,
listNoSqlItems: async (resourceId: string): Promise<NoSqlItem[]> => [{
id: '{"pk":"item-1"}',
key: {pk: 'item-1'},
document: {pk: 'item-1', name: 'First item', table: resourceId},
}],
items: {
listItems: async (resourceId) => ({items: [{
id: '{"pk":"item-1"}', collectionId: null, timestamp: null,
body: {pk: 'item-1', name: 'First item', table: resourceId}, metadata: {key: {pk: 'item-1'}},
}], nextCursor: null}),
},
})])
const res = await app.request('/api/clouds/aws/services/nosql/resources/orders/items')
const body = await res.json()
expect(res.status).toBe(200)
expect(body[0].key).toEqual({pk: 'item-1'})
expect(body[0].document.table).toBe('orders')
expect(body.items[0].metadata.key).toEqual({pk: 'item-1'})
expect(body.items[0].body.table).toBe('orders')
})
test('puts a DynamoDB record through the nosql adapter', async () => {
test('puts and deletes DynamoDB records through the generic item adapter with the complete key', async () => {
const calls: Array<{resourceId: string; document: Record<string, unknown>}> = []
const app = appWithRoutes([mockAdapter('aws', {
service: 'nosql',
schema: awsDynamoDbSchema,
putNoSqlItem: async (resourceId, document): Promise<NoSqlItem> => {
calls.push({resourceId, document})
return {id: JSON.stringify({pk: document.pk}), key: {pk: document.pk}, document}
items: {
listItems: async () => ({items: [], nextCursor: null}),
putItem: async (resourceId, document) => {
calls.push({resourceId, document})
return {id: JSON.stringify({pk: document.pk, sk: document.sk}), collectionId: null, timestamp: null, body: document, metadata: {key: {pk: document.pk, sk: document.sk}}}
},
deleteItem: async (resourceId, itemId) => {
calls.push({resourceId, document: {itemId}})
},
},
})])
const res = await app.request('/api/clouds/aws/services/nosql/resources/orders/items', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({pk: 'item-1', name: 'First item'}),
body: JSON.stringify({pk: 'item-1', sk: '2026-09-29', name: 'First item'}),
})
const body = await res.json()
expect(res.status).toBe(201)
expect(body.key).toEqual({pk: 'item-1'})
expect(calls).toEqual([{resourceId: 'orders', document: {pk: 'item-1', name: 'First item'}}])
expect(body.metadata.key).toEqual({pk: 'item-1', sk: '2026-09-29'})
expect(calls).toEqual([{resourceId: 'orders', document: {pk: 'item-1', sk: '2026-09-29', name: 'First item'}}])
const deleteRes = await app.request('/api/clouds/aws/services/nosql/resources/orders/items/%7B%22pk%22%3A%22item-1%22%2C%22sk%22%3A%222026-09-29%22%7D', {method: 'DELETE'})
expect(deleteRes.status).toBe(200)
expect(calls).toEqual([
{resourceId: 'orders', document: {pk: 'item-1', sk: '2026-09-29', name: 'First item'}},
{resourceId: 'orders', document: {itemId: '{"pk":"item-1","sk":"2026-09-29"}'}},
])
})
test('clears the SES mailbox through the email adapter', async () => {
@@ -804,6 +856,51 @@ describe('cloud schema routes', () => {
expect(res.status).toBe(400)
})
test('filters log events with pagination through the route', async () => {
let received: Record<string, unknown> = {}
const app = appWithRoutes([mockAdapter('aws', {
service: 'logs',
schema: awsLogsSchema,
filterLogEvents: async (logGroupName, input, page) => {
received = {logGroupName, input, page}
return {items: [{id: 'event-1', collectionId: 'stream-a', timestamp: null, body: {message: 'ERROR'}, metadata: {}}], nextCursor: 'next'}
},
})])
const res = await app.request('/api/clouds/aws/services/logs/resources/%2Ffloci%2Fprobe/filter?cursor=previous&limit=25', {
method: 'POST',
body: JSON.stringify({filterPattern: 'ERROR', logStreamNames: ['stream-a'], startTime: 1000, endTime: 2000}),
})
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject({nextCursor: 'next'})
expect(received).toEqual({
logGroupName: '/floci/probe',
input: {filterPattern: 'ERROR', logStreamNames: ['stream-a'], startTime: 1000, endTime: 2000},
page: {cursor: 'previous', limit: 25},
})
})
test('rejects invalid log event filters before calling the adapter', async () => {
let called = false
const app = appWithRoutes([mockAdapter('aws', {
service: 'logs',
schema: awsLogsSchema,
filterLogEvents: async () => {
called = true
return {items: [], nextCursor: null}
},
})])
const res = await app.request('/api/clouds/aws/services/logs/resources/%2Ffloci%2Fprobe/filter', {
method: 'POST',
body: JSON.stringify({logStreamNames: 'stream-a'}),
})
expect(res.status).toBe(400)
expect(called).toBe(false)
})
test('normalizes runtime unavailable errors', async () => {
const app = appWithRoutes([
mockAdapter('aws', {
@@ -1621,6 +1718,30 @@ describe('child collection routes', () => {
expect(await res.json()).toEqual({items: stubItems.items, nextCursor: null})
})
test('passes a structured flat-item query and pagination through the existing query route', async () => {
let seen: unknown
const app = appWithRoutes([mockAdapter('aws', {items: {
...itemsStub,
queryItems: async (resourceId, query, page) => {
seen = {resourceId, query, page}
return {...stubItems, count: 1, scannedCount: 4, consumedCapacity: 0.5}
},
}})])
const res = await app.request('/api/clouds/aws/services/storage/resources/table-1/query?cursor=opaque&limit=25', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({query: {operation: 'scan', filter: {attribute: 'status', operator: 'EQ', type: 'S', value: 'OPEN'}}}),
})
expect(res.status).toBe(200)
expect(seen).toEqual({
resourceId: 'table-1',
query: {operation: 'scan', filter: {attribute: 'status', operator: 'EQ', type: 'S', value: 'OPEN'}},
page: {cursor: 'opaque', limit: 25},
})
expect(await res.json()).toMatchObject({count: 1, scannedCount: 4, consumedCapacity: 0.5})
})
// A documents-only adapter has no flat shape, and vice versa.
test('501s when the adapter has the other shape', async () => {
const app = appWithRoutes([mockAdapter('aws', {documents: documentsStub})])
+109 -13
View File
@@ -264,24 +264,79 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
})
})
app.get('/:cloud/services/nosql/resources/:id/items', async (c) => {
app.post('/:cloud/services/logs/resources/:id/filter', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => {
const items = await svc(c).listNoSqlItems(cloud, c.req.param('id'))
return c.json(items)
const body = await c.req.json<{filterPattern?: unknown; logStreamNames?: unknown; startTime?: unknown; endTime?: unknown}>()
if (body.filterPattern !== undefined && typeof body.filterPattern !== 'string') throw new ValidationError('filterPattern must be a string.')
if (body.logStreamNames !== undefined && (!Array.isArray(body.logStreamNames) || body.logStreamNames.some((name) => typeof name !== 'string'))) throw new ValidationError('logStreamNames must be an array of strings.')
if (body.startTime !== undefined && typeof body.startTime !== 'number') throw new ValidationError('startTime must be a number (epoch milliseconds).')
if (body.endTime !== undefined && typeof body.endTime !== 'number') throw new ValidationError('endTime must be a number (epoch milliseconds).')
if (typeof body.startTime === 'number' && typeof body.endTime === 'number' && body.startTime > body.endTime) throw new ValidationError('startTime must not be after endTime.')
return c.json(await svc(c).filterLogEvents(cloud, c.req.param('id'), {
filterPattern: body.filterPattern,
logStreamNames: body.logStreamNames as string[] | undefined,
startTime: body.startTime,
endTime: body.endTime,
}, pageQuery(c)))
})
})
app.post('/:cloud/services/nosql/resources/:id/items', async (c) => {
app.post('/:cloud/services/messaging/resources/:id/messages', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const document = await c.req.json<Record<string, unknown>>()
const item = await svc(c).putNoSqlItem(cloud, c.req.param('id'), document)
return c.json(item, 201)
const body = await c.req.json<{messageBody?: unknown; messageAttributes?: unknown; messageGroupId?: unknown; messageDeduplicationId?: unknown}>()
if (typeof body.messageBody !== 'string') throw new ValidationError('messageBody must be a string.')
return c.json(await svc(c).sendSqsMessage(cloud, c.req.param('id'), {
messageBody: body.messageBody,
messageAttributes: body.messageAttributes as never,
messageGroupId: typeof body.messageGroupId === 'string' ? body.messageGroupId : undefined,
messageDeduplicationId: typeof body.messageDeduplicationId === 'string' ? body.messageDeduplicationId : undefined,
}), 201)
})
})
app.post('/:cloud/services/messaging/resources/:id/messages/receive', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const body = await c.req.json<{maxNumberOfMessages?: unknown}>()
if (body.maxNumberOfMessages !== undefined && typeof body.maxNumberOfMessages !== 'number') throw new ValidationError('maxNumberOfMessages must be a number.')
return c.json(await svc(c).receiveSqsMessages(cloud, c.req.param('id'), {maxNumberOfMessages: body.maxNumberOfMessages}))
})
})
app.post('/:cloud/services/messaging/resources/:id/messages/delete', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const body = await c.req.json<{receiptHandle?: unknown}>()
if (typeof body.receiptHandle !== 'string' || !body.receiptHandle) throw new ValidationError('receiptHandle is required.')
await svc(c).deleteSqsMessage(cloud, c.req.param('id'), body.receiptHandle)
return c.json({ok: true})
})
})
app.delete('/:cloud/services/messaging/resources/:id/messages', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
await svc(c).purgeSqsQueue(cloud, c.req.param('id'))
return c.json({ok: true})
})
})
app.post('/:cloud/services/messaging/resources/:id/move-tasks', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const body = await c.req.json<{destinationArn?: unknown}>()
if (body.destinationArn !== undefined && typeof body.destinationArn !== 'string') throw new ValidationError('destinationArn must be a string.')
return c.json(await svc(c).startSqsMoveTask(cloud, c.req.param('id'), {destinationArn: body.destinationArn}))
})
})
@@ -526,6 +581,8 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
const input = await c.req.json<Record<string, unknown>>()
const route = await svc(c).createApiGatewayLambdaRoute(cloud, c.req.param('id'), {
path: typeof input.path === 'string' ? input.path : '',
parentResourceId: typeof input.parentResourceId === 'string' ? input.parentResourceId : undefined,
pathPart: typeof input.pathPart === 'string' ? input.pathPart : undefined,
method: typeof input.method === 'string' ? input.method : '',
stage: typeof input.stage === 'string' ? input.stage : '',
functionName: typeof input.functionName === 'string' ? input.functionName : '',
@@ -699,6 +756,45 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
return withRuntime(c, async () => c.json(await svc(c).listEventBridgeRules(cloud, c.req.param('id'))))
})
app.post('/:cloud/services/events/resources/:id/events', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const input = await c.req.json<Record<string, unknown>>()
const result = await svc(c).putEventBridgeEvent(cloud, c.req.param('id'), {
source: typeof input.source === 'string' ? input.source : '', detailType: typeof input.detailType === 'string' ? input.detailType : '', detail: typeof input.detail === 'string' ? input.detail : '',
resources: Array.isArray(input.resources) ? input.resources.filter((resource): resource is string => typeof resource === 'string') : undefined,
})
return c.json(result, result.errorCode ? 400 : 201)
})
})
app.get('/:cloud/services/iac/resources/:id/change-sets', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).listCloudFormationChangeSets(cloud, c.req.param('id'))))
})
app.post('/:cloud/services/iac/resources/:id/change-sets', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => { const input = await c.req.json<{name?: string; templateBody?: string; parameters?: Record<string, string>; capabilities?: string[]}>(); return c.json(await svc(c).createCloudFormationChangeSet(cloud, c.req.param('id'), {name: input.name ?? '', templateBody: input.templateBody ?? '', parameters: input.parameters, capabilities: input.capabilities}), 201) })
})
app.post('/:cloud/services/iac/resources/:id/change-sets/:name/execute', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => { await svc(c).executeCloudFormationChangeSet(cloud, c.req.param('id'), c.req.param('name')); return c.json({ok: true}) })
})
app.delete('/:cloud/services/iac/resources/:id/change-sets/:name', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => { await svc(c).deleteCloudFormationChangeSet(cloud, c.req.param('id'), c.req.param('name')); return c.json({ok: true}) })
})
app.put('/:cloud/services/iac/resources/:id/termination-protection', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => { const input = await c.req.json<{enabled?: boolean}>(); await svc(c).updateCloudFormationTerminationProtection(cloud, c.req.param('id'), input.enabled === true); return c.json({ok: true}) })
})
app.post('/:cloud/services/events/resources/:id/rules', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
@@ -863,8 +959,8 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const body = await c.req.json<{query?: string}>()
const page = await svc(c).queryCollectionItems(target.cloud, target.service, c.req.param('id'), c.req.param('cid'), body.query ?? '')
const body = await c.req.json<{query?: string | Record<string, unknown>}>()
const page = await svc(c).queryCollectionItems(target.cloud, target.service, c.req.param('id'), c.req.param('cid'), body.query ?? '', pageQuery(c))
return c.json(page)
})
})
@@ -905,8 +1001,8 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const body = await c.req.json<{query?: string}>()
const page = await svc(c).queryFlatItems(target.cloud, target.service, c.req.param('id'), body.query ?? '')
const body = await c.req.json<{query?: string | Record<string, unknown>}>()
const page = await svc(c).queryFlatItems(target.cloud, target.service, c.req.param('id'), body.query ?? '', pageQuery(c))
return c.json(page)
})
})
+85 -15
View File
@@ -37,18 +37,27 @@ import type {
EventBridgeLambdaTriggerOverview,
EventBridgeRuleInput,
EventBridgeRuleOverview,
EventBridgePutEventInput,
EventBridgePutEventResult,
CloudFormationChangeSet,
CloudFormationChangeSetInput,
SqsLambdaEventSourceMappingInput,
SqsLambdaEventSourceMappingOverview,
SqsMessage,
SqsMoveTaskInput,
SqsMoveTaskResult,
SqsReceiveMessagesInput,
SqsSendMessageInput,
DatabaseSnapshot,
KubernetesFargateProfile,
KubernetesNodegroup,
LogsInsightsQueryInput,
LogsInsightsQueryResult,
LogsFilterInput,
KmsDecryptInput,
KmsDecryptResult,
KmsEncryptInput,
KmsEncryptResult,
NoSqlItem,
PublishNotificationInput,
PublishNotificationResult,
SnsSubscriptionInput,
@@ -71,6 +80,7 @@ import type {
CollectionPage,
DocumentStoreAdapter,
ItemStoreAdapter,
ItemQuery,
PageQuery,
} from '../cloud-spi/childCollections'
import {NotSupportedError} from '../cloud-spi/errors'
@@ -372,6 +382,42 @@ export class CloudProxyService {
await adapter.deleteEventBridgeRule(eventBusId, ruleName)
}
async putEventBridgeEvent(cloud: CloudProvider, eventBusId: string, input: EventBridgePutEventInput): Promise<EventBridgePutEventResult> {
const adapter = this.requireAdapter(cloud, 'events')
if (!adapter.putEventBridgeEvent) throw new NotSupportedError(`Event publishing is not supported for ${cloud}`)
return adapter.putEventBridgeEvent(eventBusId, input)
}
async listCloudFormationChangeSets(cloud: CloudProvider, stackName: string): Promise<CloudFormationChangeSet[]> {
const adapter = this.requireAdapter(cloud, 'iac')
if (!adapter.listCloudFormationChangeSets) throw new NotSupportedError(`CloudFormation change sets are not supported for ${cloud}`)
return adapter.listCloudFormationChangeSets(stackName)
}
async createCloudFormationChangeSet(cloud: CloudProvider, stackName: string, input: CloudFormationChangeSetInput): Promise<CloudFormationChangeSet> {
const adapter = this.requireAdapter(cloud, 'iac')
if (!adapter.createCloudFormationChangeSet) throw new NotSupportedError(`CloudFormation change sets are not supported for ${cloud}`)
return adapter.createCloudFormationChangeSet(stackName, input)
}
async executeCloudFormationChangeSet(cloud: CloudProvider, stackName: string, changeSetName: string): Promise<void> {
const adapter = this.requireAdapter(cloud, 'iac')
if (!adapter.executeCloudFormationChangeSet) throw new NotSupportedError(`CloudFormation change sets are not supported for ${cloud}`)
await adapter.executeCloudFormationChangeSet(stackName, changeSetName)
}
async deleteCloudFormationChangeSet(cloud: CloudProvider, stackName: string, changeSetName: string): Promise<void> {
const adapter = this.requireAdapter(cloud, 'iac')
if (!adapter.deleteCloudFormationChangeSet) throw new NotSupportedError(`CloudFormation change sets are not supported for ${cloud}`)
await adapter.deleteCloudFormationChangeSet(stackName, changeSetName)
}
async updateCloudFormationTerminationProtection(cloud: CloudProvider, stackName: string, enabled: boolean): Promise<void> {
const adapter = this.requireAdapter(cloud, 'iac')
if (!adapter.updateCloudFormationTerminationProtection) throw new NotSupportedError(`CloudFormation termination protection is not supported for ${cloud}`)
await adapter.updateCloudFormationTerminationProtection(stackName, enabled)
}
async createSqsLambdaEventSourceMapping(cloud: CloudProvider, input: SqsLambdaEventSourceMappingInput): Promise<SqsLambdaEventSourceMappingOverview> {
const adapter = this.requireAdapter(cloud, 'serverless')
if (!adapter.createSqsLambdaEventSourceMapping) throw new NotSupportedError(`SQS Lambda event source mappings are not supported for ${cloud}`)
@@ -390,6 +436,36 @@ export class CloudProxyService {
await adapter.deleteSqsLambdaEventSourceMapping(functionName, mappingId)
}
async sendSqsMessage(cloud: CloudProvider, queueId: string, input: SqsSendMessageInput): Promise<{messageId: string}> {
const adapter = this.requireAdapter(cloud, 'messaging')
if (!adapter.sendSqsMessage) throw new NotSupportedError(`SQS message sending is not supported for ${cloud}`)
return adapter.sendSqsMessage(queueId, input)
}
async receiveSqsMessages(cloud: CloudProvider, queueId: string, input: SqsReceiveMessagesInput): Promise<SqsMessage[]> {
const adapter = this.requireAdapter(cloud, 'messaging')
if (!adapter.receiveSqsMessages) throw new NotSupportedError(`SQS message receiving is not supported for ${cloud}`)
return adapter.receiveSqsMessages(queueId, input)
}
async deleteSqsMessage(cloud: CloudProvider, queueId: string, receiptHandle: string): Promise<void> {
const adapter = this.requireAdapter(cloud, 'messaging')
if (!adapter.deleteSqsMessage) throw new NotSupportedError(`SQS message deletion is not supported for ${cloud}`)
await adapter.deleteSqsMessage(queueId, receiptHandle)
}
async purgeSqsQueue(cloud: CloudProvider, queueId: string): Promise<void> {
const adapter = this.requireAdapter(cloud, 'messaging')
if (!adapter.purgeSqsQueue) throw new NotSupportedError(`SQS queue purging is not supported for ${cloud}`)
await adapter.purgeSqsQueue(queueId)
}
async startSqsMoveTask(cloud: CloudProvider, queueId: string, input: SqsMoveTaskInput): Promise<SqsMoveTaskResult> {
const adapter = this.requireAdapter(cloud, 'messaging')
if (!adapter.startSqsMoveTask) throw new NotSupportedError(`SQS message moving is not supported for ${cloud}`)
return adapter.startSqsMoveTask(queueId, input)
}
async updateResource(cloud: CloudProvider, service: CloudServiceType, id: string, input: UpdateResourceInput): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, service)
if (!adapter.update) throw new NotSupportedError(`Resource updates are not supported for ${cloud}/${service}`)
@@ -517,10 +593,10 @@ export class CloudProxyService {
await documents.deleteItem(resourceId, collectionId, itemId, partitionKey)
}
async queryCollectionItems(cloud: CloudProvider, service: CloudServiceType, resourceId: string, collectionId: string, query: string): Promise<CollectionPage<ChildItem>> {
async queryCollectionItems(cloud: CloudProvider, service: CloudServiceType, resourceId: string, collectionId: string, query: ItemQuery, page: PageQuery): Promise<CollectionPage<ChildItem>> {
const documents = this.requireDocuments(cloud, service)
if (!documents.queryItems) throw new NotSupportedError(`Querying items is not supported for ${cloud}/${service}`)
return documents.queryItems(resourceId, collectionId, query)
return documents.queryItems(resourceId, collectionId, query, page)
}
async listFlatItems(cloud: CloudProvider, service: CloudServiceType, resourceId: string, page: PageQuery): Promise<CollectionPage<ChildItem>> {
@@ -539,10 +615,10 @@ export class CloudProxyService {
await items.deleteItem(resourceId, itemId, partitionKey)
}
async queryFlatItems(cloud: CloudProvider, service: CloudServiceType, resourceId: string, query: string): Promise<CollectionPage<ChildItem>> {
async queryFlatItems(cloud: CloudProvider, service: CloudServiceType, resourceId: string, query: ItemQuery, page: PageQuery): Promise<CollectionPage<ChildItem>> {
const items = this.requireItems(cloud, service)
if (!items.queryItems) throw new NotSupportedError(`Querying items is not supported for ${cloud}/${service}`)
return items.queryItems(resourceId, query)
return items.queryItems(resourceId, query, page)
}
private requireDocuments(cloud: CloudProvider, service: CloudServiceType): DocumentStoreAdapter {
@@ -641,16 +717,10 @@ export class CloudProxyService {
return adapter.queryLogs(logGroupName, input)
}
async listNoSqlItems(cloud: CloudProvider, resourceId: string): Promise<NoSqlItem[]> {
const adapter = this.requireAdapter(cloud, 'nosql')
if (!adapter.listNoSqlItems) throw new NotSupportedError(`Item listing is not supported for ${cloud}/nosql`)
return adapter.listNoSqlItems(resourceId)
}
async putNoSqlItem(cloud: CloudProvider, resourceId: string, document: Record<string, unknown>): Promise<NoSqlItem> {
const adapter = this.requireAdapter(cloud, 'nosql')
if (!adapter.putNoSqlItem) throw new NotSupportedError(`Item creation is not supported for ${cloud}/nosql`)
return adapter.putNoSqlItem(resourceId, document)
async filterLogEvents(cloud: CloudProvider, logGroupName: string, input: LogsFilterInput, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
const adapter = this.requireAdapter(cloud, 'logs')
if (!adapter.filterLogEvents) throw new NotSupportedError(`Log event filtering is not supported for ${cloud}/logs`)
return adapter.filterLogEvents(logGroupName, input, page)
}
async listKubernetesNodegroups(cloud: CloudProvider, clusterId: string): Promise<KubernetesNodegroup[]> {