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
+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)
})
})