feat: Cloudformation + RDS
This commit is contained in:
@@ -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})])
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user