first commit
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Regression tests for issue #205:
|
||||
* "Search bar in top nav doesn't update the `search` query param"
|
||||
*
|
||||
* These tests verify that the `GET /clouds/:cloud/services/:service/resources`
|
||||
* route correctly reads the `search` URL query parameter and forwards it to
|
||||
* `CloudProxyService.listResources()` as `query.search`.
|
||||
*/
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {createCloudRoutes} from './clouds'
|
||||
import {CloudProxyService} from '../service/CloudProxyService'
|
||||
import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry'
|
||||
import type {CloudProvider, CloudResource, CloudServiceType, ResourceQuery} from '../cloud-spi/types'
|
||||
|
||||
const STUB_RESOURCE: CloudResource = {
|
||||
id: 'stub-bucket-1',
|
||||
name: 'stub-bucket-1',
|
||||
cloud: 'aws',
|
||||
service: 'storage',
|
||||
type: 'bucket',
|
||||
region: 'us-east-1',
|
||||
createdAt: '2026-09-01T00:00:00Z',
|
||||
status: 'active',
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
class StubStorageAdapter {
|
||||
readonly cloud: CloudProvider = 'aws'
|
||||
readonly service: CloudServiceType = 'storage'
|
||||
capturedQuery: ResourceQuery | undefined
|
||||
|
||||
schema() {
|
||||
return {
|
||||
cloud: 'aws' as CloudProvider,
|
||||
service: 'storage' as CloudServiceType,
|
||||
displayName: 'Storage',
|
||||
actions: ['list'] as const,
|
||||
columns: [{name: 'id', label: 'ID', path: 'id'}],
|
||||
availability: 'available' as const,
|
||||
}
|
||||
}
|
||||
|
||||
async list(query?: ResourceQuery): Promise<CloudResource[]> {
|
||||
this.capturedQuery = query
|
||||
const term = query?.search?.toLowerCase()
|
||||
if (!term) return [STUB_RESOURCE]
|
||||
return STUB_RESOURCE.name.toLowerCase().includes(term) ? [STUB_RESOURCE] : []
|
||||
}
|
||||
}
|
||||
|
||||
function buildApp(adapter: StubStorageAdapter) {
|
||||
const registry = {
|
||||
get: (cloud: CloudProvider, service: CloudServiceType) =>
|
||||
cloud === 'aws' && service === 'storage' ? adapter : undefined,
|
||||
servicesFor: () => [],
|
||||
clouds: () => [],
|
||||
} as unknown as CloudAdapterRegistry
|
||||
|
||||
const service = new CloudProxyService(registry)
|
||||
return createCloudRoutes(service)
|
||||
}
|
||||
|
||||
describe('GET /clouds/:cloud/services/:service/resources - search query param (#205)', () => {
|
||||
test('forwards the search param to listResources when provided', async () => {
|
||||
const adapter = new StubStorageAdapter()
|
||||
const app = buildApp(adapter)
|
||||
|
||||
const response = await app.request('/aws/services/storage/resources?search=stub')
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(adapter.capturedQuery?.search).toBe('stub')
|
||||
})
|
||||
|
||||
test('passes undefined search when no param supplied', async () => {
|
||||
const adapter = new StubStorageAdapter()
|
||||
const app = buildApp(adapter)
|
||||
|
||||
const response = await app.request('/aws/services/storage/resources')
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(adapter.capturedQuery?.search).toBeUndefined()
|
||||
})
|
||||
|
||||
test('filters resources by search term via adapter', async () => {
|
||||
const adapter = new StubStorageAdapter()
|
||||
const app = buildApp(adapter)
|
||||
|
||||
const hitResponse = await app.request('/aws/services/storage/resources?search=stub')
|
||||
const hitBody = await hitResponse.json() as CloudResource[]
|
||||
expect(hitBody).toHaveLength(1)
|
||||
|
||||
const missResponse = await app.request('/aws/services/storage/resources?search=dynamo')
|
||||
const missBody = await missResponse.json() as CloudResource[]
|
||||
expect(missBody).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('returns 404 for unknown cloud', async () => {
|
||||
const adapter = new StubStorageAdapter()
|
||||
const app = buildApp(adapter)
|
||||
|
||||
const response = await app.request('/unknown-cloud/services/storage/resources?search=test')
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,910 @@
|
||||
import {Hono} from 'hono'
|
||||
import type {Context} from 'hono'
|
||||
import type {
|
||||
CloudProvider,
|
||||
CloudServiceType,
|
||||
CreateDatabaseSnapshotInput,
|
||||
KmsEncryptionAlgorithm,
|
||||
SqlConnectionInput,
|
||||
} from '../cloud-spi/types'
|
||||
import {clampLimit, type PageQuery} from '../cloud-spi/childCollections'
|
||||
import {CloudError, toHttpError, ValidationError} from '../cloud-spi/errors'
|
||||
import {isServiceType} from '../cloud-spi/serviceCatalog'
|
||||
import {mapAwsSdkError} from '../adapter-aws/awsErrors'
|
||||
import {serviceForAccount} from '../cloudProxy'
|
||||
import {CloudProxyService} from '../service/CloudProxyService'
|
||||
|
||||
// Header (and query-param fallback for direct links such as object downloads)
|
||||
// used by the frontend to scope every request to an AWS account.
|
||||
export const ACCOUNT_HEADER = 'x-floci-account-id'
|
||||
|
||||
interface KmsEncryptRequest {
|
||||
plaintextBase64?: unknown
|
||||
encryptionAlgorithm?: unknown
|
||||
encryptionContext?: unknown
|
||||
}
|
||||
|
||||
interface KmsDecryptRequest {
|
||||
ciphertextBlobBase64?: unknown
|
||||
encryptionAlgorithm?: unknown
|
||||
encryptionContext?: unknown
|
||||
}
|
||||
|
||||
const KMS_ENCRYPTION_ALGORITHMS = new Set<KmsEncryptionAlgorithm>([
|
||||
'SYMMETRIC_DEFAULT',
|
||||
'RSAES_OAEP_SHA_1',
|
||||
'RSAES_OAEP_SHA_256',
|
||||
])
|
||||
const MAX_PLAINTEXT_BASE64_LENGTH = 5_464
|
||||
const MAX_CIPHERTEXT_BASE64_LENGTH = 8_192
|
||||
|
||||
export function createCloudRoutes(injectedService?: CloudProxyService) {
|
||||
const app = new Hono()
|
||||
|
||||
// Resolve the account-scoped service per request. An explicitly injected
|
||||
// service (used by tests) always wins and ignores the account header.
|
||||
const svc = (c: Context): CloudProxyService =>
|
||||
injectedService ?? serviceForAccount(c.req.header(ACCOUNT_HEADER) ?? c.req.query('account'))
|
||||
|
||||
app.get('/', (c) => c.json(svc(c).clouds()))
|
||||
|
||||
app.get('/:cloud/services', (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
return c.json(svc(c).services(cloud))
|
||||
})
|
||||
|
||||
app.get('/:cloud/status', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
// Per-service detail is opt-in; the connection indicator polls this often.
|
||||
const includeServices = c.req.query('services') === 'all'
|
||||
return c.json(await svc(c).status(cloud, {includeServices}))
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/:service/status', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
const serviceType = c.req.param('service') as CloudServiceType
|
||||
if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return c.json(await svc(c).serviceStatus(cloud, serviceType))
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/:service/schema', (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
const serviceType = c.req.param('service') as CloudServiceType
|
||||
if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
const schema = svc(c).schema(cloud, serviceType)
|
||||
if (!schema) {
|
||||
return c.json({error: 'Schema not available'}, 404)
|
||||
}
|
||||
|
||||
return c.json(schema)
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/database/snapshots', 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 snapshots = await svc(c).listDatabaseSnapshots(cloud, c.req.query('instanceIdentifier'))
|
||||
return c.json(snapshots)
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/database/snapshots', 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 input = await c.req.json<CreateDatabaseSnapshotInput>()
|
||||
const snapshot = await svc(c).createDatabaseSnapshot(cloud, input)
|
||||
return c.json(snapshot, 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/database/orderable-classes', 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 classes = await svc(c).listDatabaseOrderableInstanceClasses(cloud, c.req.query('engine'))
|
||||
return c.json(classes)
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/:service/resources', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
const serviceType = c.req.param('service') as CloudServiceType
|
||||
if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const resources = await svc(c).listResources(cloud, serviceType, {search: c.req.query('search')})
|
||||
return c.json(resources)
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/nosql/resources/:id/containers', 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 containers = await svc(c).listCosmosContainers(cloud, c.req.param('id'))
|
||||
return c.json(containers)
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/nosql/resources/:id/containers', 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 values = await c.req.json<Record<string, unknown>>()
|
||||
const container = await svc(c).createCosmosContainer(cloud, c.req.param('id'), {values})
|
||||
return c.json(container, 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/nosql/resources/:id/containers/:containerId', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteCosmosContainer(cloud, c.req.param('id'), c.req.param('containerId'))
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/nosql/resources/:id/containers/:containerId/items', 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).listCosmosItems(cloud, c.req.param('id'), c.req.param('containerId'))
|
||||
return c.json(items)
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/nosql/resources/:id/containers/:containerId/items', 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 document = await c.req.json<Record<string, unknown>>()
|
||||
const item = await svc(c).upsertCosmosItem(cloud, c.req.param('id'), c.req.param('containerId'), document)
|
||||
return c.json(item, 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/nosql/resources/:id/containers/:containerId/items/:itemId', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteCosmosItem(cloud, c.req.param('id'), c.req.param('containerId'), c.req.param('itemId'), c.req.query('partitionKey') ?? null)
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/nosql/resources/:id/containers/:containerId/query', 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 body = await c.req.json<{query?: string}>()
|
||||
const result = await svc(c).queryCosmosItems(cloud, c.req.param('id'), c.req.param('containerId'), body.query ?? '')
|
||||
return c.json(result)
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/database/resources/:id/sql/databases', 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 connection = await c.req.json<SqlConnectionInput>()
|
||||
const databases = await svc(c).listSqlDatabases(cloud, c.req.param('id'), connection)
|
||||
return c.json(databases)
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/database/resources/:id/sql/tables', 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 connection = await c.req.json<SqlConnectionInput>()
|
||||
const tables = await svc(c).listSqlTables(cloud, c.req.param('id'), connection)
|
||||
return c.json(tables)
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/database/resources/:id/sql/query', 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 body = await c.req.json<SqlConnectionInput & {query?: string}>()
|
||||
const {query = '', ...connection} = body
|
||||
const result = await svc(c).querySql(cloud, c.req.param('id'), connection, query)
|
||||
return c.json(result)
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/logs/resources/:id/query', 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 body = await c.req.json<{queryString?: unknown; startTime?: unknown; endTime?: unknown; limit?: unknown}>()
|
||||
if (typeof body.queryString !== 'string') throw new ValidationError('queryString must be a string.')
|
||||
if (typeof body.startTime !== 'number') throw new ValidationError('startTime must be a number (epoch seconds).')
|
||||
if (typeof body.endTime !== 'number') throw new ValidationError('endTime must be a number (epoch seconds).')
|
||||
if (body.limit !== undefined && typeof body.limit !== 'number') throw new ValidationError('limit must be a number.')
|
||||
|
||||
const result = await svc(c).queryLogs(cloud, c.req.param('id'), {
|
||||
queryString: body.queryString,
|
||||
startTime: body.startTime,
|
||||
endTime: body.endTime,
|
||||
limit: body.limit,
|
||||
})
|
||||
return c.json(result)
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/nosql/resources/:id/items', 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)
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/nosql/resources/:id/items', 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 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)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/email/inbox', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).clearEmailInbox(cloud)
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/k8s/resources/:id/nodegroups', 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 nodegroups = await svc(c).listKubernetesNodegroups(cloud, c.req.param('id'))
|
||||
return c.json(nodegroups)
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/k8s/resources/:id/nodegroups', 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 nodegroup = await svc(c).createKubernetesNodegroup(cloud, c.req.param('id'), await c.req.json())
|
||||
return c.json(nodegroup, 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/k8s/resources/:id/nodegroups/:nodegroupId', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteKubernetesNodegroup(cloud, c.req.param('id'), c.req.param('nodegroupId'))
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/k8s/resources/:id/fargate-profiles', 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 profiles = await svc(c).listKubernetesFargateProfiles(cloud, c.req.param('id'))
|
||||
return c.json(profiles)
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/k8s/resources/:id/fargate-profiles', 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 profile = await svc(c).createKubernetesFargateProfile(cloud, c.req.param('id'), await c.req.json())
|
||||
return c.json(profile, 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/k8s/resources/:id/fargate-profiles/:profileId', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteKubernetesFargateProfile(cloud, c.req.param('id'), c.req.param('profileId'))
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/configuration/resources/:id/environments', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
return c.json(await svc(c).listAppConfigEnvironments(cloud, c.req.param('id')))
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/configuration/resources/:id/environments', 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 values = await c.req.json<Record<string, unknown>>()
|
||||
return c.json(await svc(c).createAppConfigEnvironment(cloud, c.req.param('id'), {values}), 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/configuration/resources/:id/environments/:environmentId', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteAppConfigEnvironment(cloud, c.req.param('id'), c.req.param('environmentId'))
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/configuration/resources/:id/configuration-profiles', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
return c.json(await svc(c).listAppConfigConfigurationProfiles(cloud, c.req.param('id')))
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/configuration/resources/:id/configuration-profiles', 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 values = await c.req.json<Record<string, unknown>>()
|
||||
return c.json(await svc(c).createAppConfigConfigurationProfile(cloud, c.req.param('id'), {values}), 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteAppConfigConfigurationProfile(cloud, c.req.param('id'), c.req.param('profileId'))
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId/hosted-configuration-versions', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
return c.json(await svc(c).listAppConfigHostedConfigurationVersions(cloud, c.req.param('id'), c.req.param('profileId')))
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId/hosted-configuration-versions', 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 values = await c.req.json<Record<string, unknown>>()
|
||||
return c.json(await svc(c).createAppConfigHostedConfigurationVersion(cloud, c.req.param('id'), c.req.param('profileId'), {values}), 201)
|
||||
})
|
||||
})
|
||||
|
||||
const hostedVersionNumber = (raw: string): number | null => {
|
||||
const value = Number(raw)
|
||||
return Number.isInteger(value) && value > 0 ? value : null
|
||||
}
|
||||
|
||||
app.get('/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId/hosted-configuration-versions/:versionNumber', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
const versionNumber = hostedVersionNumber(c.req.param('versionNumber'))
|
||||
if (!isCloudProvider(cloud) || versionNumber === null) return c.json({error: 'Unknown cloud or version'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const version = await svc(c).getAppConfigHostedConfigurationVersion(cloud, c.req.param('id'), c.req.param('profileId'), versionNumber)
|
||||
if (!version) return c.json({error: 'Hosted configuration version not found'}, 404)
|
||||
return c.json(version)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId/hosted-configuration-versions/:versionNumber', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
const versionNumber = hostedVersionNumber(c.req.param('versionNumber'))
|
||||
if (!isCloudProvider(cloud) || versionNumber === null) return c.json({error: 'Unknown cloud or version'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteAppConfigHostedConfigurationVersion(cloud, c.req.param('id'), c.req.param('profileId'), versionNumber)
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/configuration/deployment-strategies', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
return c.json(await svc(c).listAppConfigDeploymentStrategies(cloud))
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/configuration/deployment-strategies', 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 values = await c.req.json<Record<string, unknown>>()
|
||||
return c.json(await svc(c).createAppConfigDeploymentStrategy(cloud, {values}), 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/configuration/deployment-strategies/:strategyId', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteAppConfigDeploymentStrategy(cloud, c.req.param('strategyId'))
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/configuration/resources/:id/environments/:environmentId/deployments', 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 values = await c.req.json<Record<string, unknown>>()
|
||||
return c.json(await svc(c).startAppConfigDeployment(cloud, c.req.param('id'), c.req.param('environmentId'), {values}), 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/configuration/resources/:id/environments/:environmentId/deployments/:deploymentNumber', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
const deploymentNumber = Number(c.req.param('deploymentNumber'))
|
||||
if (!isCloudProvider(cloud) || !Number.isInteger(deploymentNumber) || deploymentNumber <= 0) {
|
||||
return c.json({error: 'Unknown cloud or deployment'}, 404)
|
||||
}
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const deployment = await svc(c).getAppConfigDeployment(cloud, c.req.param('id'), c.req.param('environmentId'), deploymentNumber)
|
||||
if (!deployment) return c.json({error: 'Deployment not found'}, 404)
|
||||
return c.json(deployment)
|
||||
})
|
||||
})
|
||||
|
||||
// Child collections, parameterised by service. All the literal-segment
|
||||
// routes above (Cosmos containers, SQL, NoSQL items, email inbox, k8s
|
||||
// nodegroups/fargate profiles) must stay registered before these, or the
|
||||
// `:service` param below would swallow their requests first.
|
||||
|
||||
app.post('/:cloud/services/apigateway/resources/:id/routes', 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 route = await svc(c).createApiGatewayLambdaRoute(cloud, c.req.param('id'), {
|
||||
path: typeof input.path === 'string' ? input.path : '',
|
||||
method: typeof input.method === 'string' ? input.method : '',
|
||||
stage: typeof input.stage === 'string' ? input.stage : '',
|
||||
functionName: typeof input.functionName === 'string' ? input.functionName : '',
|
||||
})
|
||||
return c.json(route, 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/:service/resources/:id/collections', async (c) => {
|
||||
const target = childTarget(c)
|
||||
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const page = await svc(c).listChildCollections(target.cloud, target.service, c.req.param('id'), pageQuery(c))
|
||||
return c.json(page)
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/:service/resources/:id/collections', async (c) => {
|
||||
const target = childTarget(c)
|
||||
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const values = await c.req.json<Record<string, unknown>>()
|
||||
const collection = await svc(c).createChildCollection(target.cloud, target.service, c.req.param('id'), {values})
|
||||
return c.json(collection, 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/:service/resources/:id/collections/:cid', async (c) => {
|
||||
const target = childTarget(c)
|
||||
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteChildCollection(target.cloud, target.service, c.req.param('id'), c.req.param('cid'))
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/:service/resources/:id/collections/:cid/items', async (c) => {
|
||||
const target = childTarget(c)
|
||||
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const page = await svc(c).listCollectionItems(target.cloud, target.service, c.req.param('id'), c.req.param('cid'), pageQuery(c))
|
||||
return c.json(page)
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/:service/resources/:id/collections/:cid/items', async (c) => {
|
||||
const target = childTarget(c)
|
||||
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const body = await c.req.json<Record<string, unknown>>()
|
||||
const item = await svc(c).putCollectionItem(target.cloud, target.service, c.req.param('id'), c.req.param('cid'), body)
|
||||
return c.json(item, 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/:service/resources/:id/collections/:cid/items/:itemId', async (c) => {
|
||||
const target = childTarget(c)
|
||||
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteCollectionItem(target.cloud, target.service, c.req.param('id'), c.req.param('cid'), c.req.param('itemId'), c.req.query('partitionKey') ?? null)
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/:service/resources/:id/collections/:cid/query', async (c) => {
|
||||
const target = childTarget(c)
|
||||
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 ?? '')
|
||||
return c.json(page)
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/:service/resources/:id/items', async (c) => {
|
||||
const target = childTarget(c)
|
||||
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const page = await svc(c).listFlatItems(target.cloud, target.service, c.req.param('id'), pageQuery(c))
|
||||
return c.json(page)
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/:service/resources/:id/items', async (c) => {
|
||||
const target = childTarget(c)
|
||||
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const body = await c.req.json<Record<string, unknown>>()
|
||||
const item = await svc(c).putFlatItem(target.cloud, target.service, c.req.param('id'), body)
|
||||
return c.json(item, 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/:service/resources/:id/items/:itemId', async (c) => {
|
||||
const target = childTarget(c)
|
||||
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteFlatItem(target.cloud, target.service, c.req.param('id'), c.req.param('itemId'), c.req.query('partitionKey') ?? null)
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/:service/resources/:id/query', async (c) => {
|
||||
const target = childTarget(c)
|
||||
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 ?? '')
|
||||
return c.json(page)
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/:service/resources/:id', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
const serviceType = c.req.param('service') as CloudServiceType
|
||||
if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const resource = await svc(c).getResource(cloud, serviceType, c.req.param('id'))
|
||||
if (!resource) return c.json({error: 'Resource not found'}, 404)
|
||||
return c.json(resource)
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/:service/resources/:id/objects', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
const serviceType = c.req.param('service') as CloudServiceType
|
||||
if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const objects = await svc(c).listObjects(cloud, serviceType, c.req.param('id'), c.req.query('prefix') ?? '')
|
||||
return c.json(objects)
|
||||
})
|
||||
})
|
||||
|
||||
app.put('/:cloud/services/:service/resources/:id/object', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
const serviceType = c.req.param('service') as CloudServiceType
|
||||
if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
const key = c.req.query('key') ?? ''
|
||||
if (!key) return c.json({error: 'Object key is required'}, 400)
|
||||
const body = new Uint8Array(await c.req.arrayBuffer())
|
||||
const contentType = c.req.header('content-type') ?? 'application/octet-stream'
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).putObject(cloud, serviceType, c.req.param('id'), key, body, contentType)
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/:service/resources/:id/object', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
const serviceType = c.req.param('service') as CloudServiceType
|
||||
if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
const key = c.req.query('key') ?? ''
|
||||
if (!key) return c.json({error: 'Object key is required'}, 400)
|
||||
return withRuntime(c, async () => {
|
||||
const object = await svc(c).getObject(cloud, serviceType, c.req.param('id'), key)
|
||||
return new Response(object.body, {
|
||||
headers: {
|
||||
'content-type': object.contentType,
|
||||
...(object.contentLength === null ? {} : {'content-length': String(object.contentLength)}),
|
||||
'content-disposition': `attachment; filename="${key.split('/').pop() ?? key}"`,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/:service/resources/:id/object', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
const serviceType = c.req.param('service') as CloudServiceType
|
||||
if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
const key = c.req.query('key') ?? ''
|
||||
if (!key) return c.json({error: 'Object key is required'}, 400)
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteObject(cloud, serviceType, c.req.param('id'), key)
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/:service/resources/:id/object/copy', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
const serviceType = c.req.param('service') as CloudServiceType
|
||||
if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
const {srcKey, destKey, destResourceId} = await c.req.json<{srcKey: string; destKey: string; destResourceId?: string}>()
|
||||
if (!srcKey || !destKey) return c.json({error: 'srcKey and destKey are required'}, 400)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).copyObject(cloud, serviceType, c.req.param('id'), srcKey, destKey, destResourceId)
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/:service/resources/:id/invoke', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
const serviceType = c.req.param('service') as CloudServiceType
|
||||
if (!isCloudProvider(cloud) || !isServiceType(serviceType)) {
|
||||
return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
}
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const body: {payload?: string} = await c.req.json<{payload?: string}>().catch(() => ({}))
|
||||
const result = await svc(c).invokeResource(
|
||||
cloud,
|
||||
serviceType,
|
||||
c.req.param('id'),
|
||||
body.payload ?? '{}',
|
||||
)
|
||||
return c.json(result)
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/kms/resources/:id/encrypt', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withSensitiveRuntime(c, async () => {
|
||||
const body = await jsonBody<KmsEncryptRequest>(c)
|
||||
const result = await svc(c).encryptKms(cloud, c.req.param('id'), {
|
||||
plaintext: decodeBase64(body.plaintextBase64, 'plaintextBase64', MAX_PLAINTEXT_BASE64_LENGTH),
|
||||
encryptionAlgorithm: encryptionAlgorithm(body.encryptionAlgorithm),
|
||||
encryptionContext: encryptionContext(body.encryptionContext),
|
||||
})
|
||||
return c.json({
|
||||
ciphertextBlobBase64: Buffer.from(result.ciphertextBlob).toString('base64'),
|
||||
keyId: result.keyId,
|
||||
encryptionAlgorithm: result.encryptionAlgorithm,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/kms/resources/:id/decrypt', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withSensitiveRuntime(c, async () => {
|
||||
const body = await jsonBody<KmsDecryptRequest>(c)
|
||||
const result = await svc(c).decryptKms(cloud, c.req.param('id'), {
|
||||
ciphertextBlob: decodeBase64(
|
||||
body.ciphertextBlobBase64,
|
||||
'ciphertextBlobBase64',
|
||||
MAX_CIPHERTEXT_BASE64_LENGTH,
|
||||
),
|
||||
encryptionAlgorithm: encryptionAlgorithm(body.encryptionAlgorithm),
|
||||
encryptionContext: encryptionContext(body.encryptionContext),
|
||||
})
|
||||
return c.json({
|
||||
plaintextBase64: Buffer.from(result.plaintext).toString('base64'),
|
||||
keyId: result.keyId,
|
||||
encryptionAlgorithm: result.encryptionAlgorithm,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/:service/resources', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
const serviceType = c.req.param('service') as CloudServiceType
|
||||
if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const values = await c.req.json<Record<string, unknown>>()
|
||||
const resource = await svc(c).createResource(cloud, serviceType, {values})
|
||||
return c.json(resource, 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.patch('/:cloud/services/:service/resources/:id', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
const serviceType = c.req.param('service') as CloudServiceType
|
||||
if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const values = await c.req.json<Record<string, unknown>>()
|
||||
const resource = await svc(c).updateResource(cloud, serviceType, c.req.param('id'), {values})
|
||||
return c.json(resource, 200)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/:service/resources/:id', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
const serviceType = c.req.param('service') as CloudServiceType
|
||||
if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteResource(cloud, serviceType, c.req.param('id'))
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
function isCloudProvider(value: string): value is CloudProvider {
|
||||
return value === 'aws' || value === 'azure' || value === 'gcp'
|
||||
}
|
||||
|
||||
async function jsonBody<T>(c: Context): Promise<T> {
|
||||
try {
|
||||
const body = await c.req.json<unknown>()
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
throw new ValidationError('Request body must be a JSON object')
|
||||
}
|
||||
return body as T
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) throw error
|
||||
throw new ValidationError('Request body must be valid JSON')
|
||||
}
|
||||
}
|
||||
|
||||
function encryptionAlgorithm(value: unknown): KmsEncryptionAlgorithm {
|
||||
if (typeof value !== 'string' || !KMS_ENCRYPTION_ALGORITHMS.has(value as KmsEncryptionAlgorithm)) {
|
||||
throw new ValidationError('encryptionAlgorithm must be a supported KMS encryption algorithm')
|
||||
}
|
||||
return value as KmsEncryptionAlgorithm
|
||||
}
|
||||
|
||||
function encryptionContext(value: unknown): Record<string, string> | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new ValidationError('encryptionContext must be an object of string values')
|
||||
}
|
||||
|
||||
const entries = Object.entries(value)
|
||||
if (entries.some(([, entryValue]) => typeof entryValue !== 'string')) {
|
||||
throw new ValidationError('encryptionContext must be an object of string values')
|
||||
}
|
||||
return entries.length === 0 ? undefined : Object.fromEntries(entries) as Record<string, string>
|
||||
}
|
||||
|
||||
function decodeBase64(value: unknown, field: string, maxLength: number): Uint8Array {
|
||||
if (typeof value !== 'string') throw new ValidationError(`${field} must be a base64 string`)
|
||||
if (value.length > maxLength) throw new ValidationError(`${field} is too large`)
|
||||
if (value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
|
||||
throw new ValidationError(`${field} must be canonical base64`)
|
||||
}
|
||||
|
||||
const decoded = Buffer.from(value, 'base64')
|
||||
if (decoded.toString('base64') !== value) throw new ValidationError(`${field} must be canonical base64`)
|
||||
return decoded
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and validate the cloud/service pair for a child-collection route.
|
||||
* Returns null when either is unknown, which the caller turns into a 404.
|
||||
*/
|
||||
function childTarget(c: Context): {cloud: CloudProvider; service: CloudServiceType} | null {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
const service = c.req.param('service') as CloudServiceType
|
||||
if (!isCloudProvider(cloud) || !isServiceType(service)) return null
|
||||
return {cloud, service}
|
||||
}
|
||||
|
||||
/** Parse paging params. An out-of-range limit raises and becomes a 400. */
|
||||
function pageQuery(c: Context): PageQuery {
|
||||
return {cursor: c.req.query('cursor'), limit: clampLimit(c.req.query('limit'))}
|
||||
}
|
||||
|
||||
async function withRuntime(c: Context, handler: () => Promise<Response>): Promise<Response> {
|
||||
try {
|
||||
return await handler()
|
||||
} catch (err) {
|
||||
const {status, body} = toHttpError(err, mapAwsSdkError)
|
||||
return c.json(body, status)
|
||||
}
|
||||
}
|
||||
|
||||
async function withSensitiveRuntime(c: Context, handler: () => Promise<Response>): Promise<Response> {
|
||||
c.header('cache-control', 'no-store')
|
||||
try {
|
||||
return await handler()
|
||||
} catch (err) {
|
||||
const {status, body} = toHttpError(err, mapAwsSdkError)
|
||||
if (err instanceof CloudError) return c.json(body, status)
|
||||
|
||||
const message = body.code === 'invalid_request' ? 'KMS rejected the request' : body.error
|
||||
return c.json({error: message, code: body.code, message}, status)
|
||||
}
|
||||
}
|
||||
|
||||
export default createCloudRoutes()
|
||||
@@ -0,0 +1,512 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {createEc2Router} from './ec2'
|
||||
import type {
|
||||
CreateAmiInput,
|
||||
CreateNatGatewayInput,
|
||||
CreateRouteInput,
|
||||
Ec2ConsoleOutput,
|
||||
Ec2ElasticIp,
|
||||
Ec2Image,
|
||||
Ec2Instance,
|
||||
Ec2InternetGateway,
|
||||
Ec2IpPermission,
|
||||
Ec2KeyPair,
|
||||
Ec2KeyPairMaterial,
|
||||
Ec2NatGateway,
|
||||
Ec2RouteTable,
|
||||
Ec2SecurityGroup,
|
||||
Ec2Subnet,
|
||||
Ec2Tag,
|
||||
Ec2Vpc,
|
||||
IpPermissionInput,
|
||||
RunInstanceInput,
|
||||
} from '../services/ec2'
|
||||
|
||||
const baseInstance: Ec2Instance = {
|
||||
instanceId: 'i-test1',
|
||||
name: 'test-instance',
|
||||
state: 'running',
|
||||
instanceType: 't3.micro',
|
||||
availabilityZone: 'us-east-1a',
|
||||
securityGroups: [],
|
||||
tags: [],
|
||||
}
|
||||
|
||||
const baseIgw: Ec2InternetGateway = {
|
||||
internetGatewayId: 'igw-0a1b2c3d4e5f6a7b',
|
||||
attachments: [],
|
||||
tags: [],
|
||||
}
|
||||
|
||||
const baseNatGw: Ec2NatGateway = {
|
||||
natGatewayId: 'nat-0a1b2c3d4e5f6a7b',
|
||||
subnetId: 'subnet-0a1b2c3d4e5f0001',
|
||||
vpcId: 'vpc-0a1b2c3d4e5f6a7b',
|
||||
state: 'available',
|
||||
tags: [],
|
||||
}
|
||||
|
||||
const baseRtb: Ec2RouteTable = {
|
||||
routeTableId: 'rtb-0a1b2c3d4e5f6a7b',
|
||||
vpcId: 'vpc-0a1b2c3d4e5f6a7b',
|
||||
routes: [{destinationCidrBlock: '10.0.0.0/16', gatewayId: 'local', state: 'active', origin: 'CreateRouteTable'}],
|
||||
associations: [],
|
||||
tags: [],
|
||||
}
|
||||
|
||||
const baseEip: Ec2ElasticIp = {
|
||||
allocationId: 'eipalloc-0a1b2c3d4e5f6a7b',
|
||||
publicIp: '54.0.0.1',
|
||||
domain: 'vpc',
|
||||
tags: [],
|
||||
}
|
||||
|
||||
const fakeService = {
|
||||
listInstances: async (): Promise<Ec2Instance[]> => [baseInstance],
|
||||
describeInstance: async (id: string): Promise<Ec2Instance> => ({...baseInstance, instanceId: id}),
|
||||
runInstance: async (_input: RunInstanceInput): Promise<Ec2Instance> => ({...baseInstance, instanceId: 'i-new1', name: _input.name, state: 'pending'}),
|
||||
startInstance: async () => {},
|
||||
stopInstance: async () => {},
|
||||
rebootInstance: async () => {},
|
||||
terminateInstance: async () => {},
|
||||
updateInstanceTags: async (_id: string, _add: Ec2Tag[], _rem: string[]) => {},
|
||||
createImage: async (_input: CreateAmiInput): Promise<{imageId: string}> => ({imageId: 'ami-0a1b2c3d4e5f6a7b'}),
|
||||
deregisterImage: async () => {},
|
||||
getConsoleOutput: async (instanceId: string): Promise<Ec2ConsoleOutput> => ({instanceId, output: 'boot log'}),
|
||||
listAmis: async (): Promise<Ec2Image[]> => [],
|
||||
listKeyPairs: async (): Promise<Ec2KeyPair[]> => [],
|
||||
createKeyPair: async (name: string): Promise<Ec2KeyPairMaterial> => ({keyPairId: 'key-0a1b2c3d4e5f6a7b', keyName: name, keyMaterial: '---PEM---'}),
|
||||
deleteKeyPair: async () => {},
|
||||
listSecurityGroups: async (): Promise<Ec2SecurityGroup[]> => [],
|
||||
createSecurityGroup: async () => ({groupId: 'sg-0a1b2c3d4e5f6a7b'}),
|
||||
deleteSecurityGroup: async () => {},
|
||||
authorizeSecurityGroupIngress: async (_id: string, _p: IpPermissionInput) => {},
|
||||
revokeSecurityGroupIngress: async (_id: string, _p: IpPermissionInput) => {},
|
||||
authorizeSecurityGroupEgress: async (_id: string, _p: IpPermissionInput) => {},
|
||||
revokeSecurityGroupEgress: async (_id: string, _p: IpPermissionInput) => {},
|
||||
listVpcs: async (): Promise<Ec2Vpc[]> => [],
|
||||
createVpc: async (cidrBlock: string): Promise<Ec2Vpc> => ({vpcId: 'vpc-0a1b2c3d4e5f6a7b', cidrBlock, isDefault: false, tags: []}),
|
||||
deleteVpc: async () => {},
|
||||
listSubnets: async (): Promise<Ec2Subnet[]> => [],
|
||||
createSubnet: async (vpcId: string, cidrBlock: string): Promise<Ec2Subnet> => ({
|
||||
subnetId: 'subnet-0a1b2c3d4e5f6a7b',
|
||||
vpcId,
|
||||
cidrBlock,
|
||||
availabilityZone: 'us-east-1a',
|
||||
mapPublicIpOnLaunch: false,
|
||||
tags: [],
|
||||
}),
|
||||
deleteSubnet: async () => {},
|
||||
// Internet Gateways
|
||||
listInternetGateways: async (): Promise<Ec2InternetGateway[]> => [baseIgw],
|
||||
createInternetGateway: async (): Promise<Ec2InternetGateway> => baseIgw,
|
||||
attachInternetGateway: async () => {},
|
||||
detachInternetGateway: async () => {},
|
||||
deleteInternetGateway: async () => {},
|
||||
// NAT Gateways
|
||||
listNatGateways: async (): Promise<Ec2NatGateway[]> => [baseNatGw],
|
||||
createNatGateway: async (_input: CreateNatGatewayInput): Promise<Ec2NatGateway> => baseNatGw,
|
||||
deleteNatGateway: async () => {},
|
||||
// Route Tables
|
||||
listRouteTables: async (): Promise<Ec2RouteTable[]> => [baseRtb],
|
||||
createRouteTable: async (): Promise<Ec2RouteTable> => baseRtb,
|
||||
deleteRouteTable: async () => {},
|
||||
createRoute: async (_rtbId: string, _input: CreateRouteInput) => {},
|
||||
deleteRoute: async () => {},
|
||||
associateRouteTable: async (): Promise<string> => 'rtbassoc-0a1b2c3d4e5f6a7b',
|
||||
disassociateRouteTable: async () => {},
|
||||
// Elastic IPs
|
||||
listElasticIps: async (): Promise<Ec2ElasticIp[]> => [baseEip],
|
||||
allocateElasticIp: async (): Promise<Ec2ElasticIp> => baseEip,
|
||||
releaseElasticIp: async () => {},
|
||||
associateElasticIp: async (): Promise<{associationId: string}> => ({associationId: 'eipassoc-test'}),
|
||||
disassociateElasticIp: async () => {},
|
||||
// Meta
|
||||
listAvailabilityZones: async () => [{zoneName: 'us-east-1a', zoneId: 'use1-az1', state: 'available'}],
|
||||
listInstanceTypes: async () => [{instanceType: 't3.micro', vcpu: 2, memoryMiB: 1024}],
|
||||
// VPC attributes
|
||||
getVpcAttributes: async () => ({enableDnsHostnames: true, enableDnsSupport: true}),
|
||||
modifyVpcAttribute: async () => {},
|
||||
// Subnet attribute
|
||||
modifySubnetMapPublicIp: async () => {},
|
||||
// VPC Wizard
|
||||
createVpcWizard: async () => ({
|
||||
vpcId: 'vpc-0a1b2c3d4e5f6a7b',
|
||||
igwId: 'igw-0a1b2c3d4e5f6a7b',
|
||||
publicSubnetIds: ['subnet-0a1b2c3d4e5f0001', 'subnet-0a1b2c3d4e5f0002'],
|
||||
privateSubnetIds: ['subnet-0a1b2c3d4e5f0003', 'subnet-0a1b2c3d4e5f0004'],
|
||||
subnetGroups: [
|
||||
{name: 'public', isPublic: true, subnetIds: ['subnet-0a1b2c3d4e5f0001', 'subnet-0a1b2c3d4e5f0002']},
|
||||
{name: 'private', isPublic: false, subnetIds: ['subnet-0a1b2c3d4e5f0003', 'subnet-0a1b2c3d4e5f0004']},
|
||||
],
|
||||
publicRouteTableId: 'rtb-0a1b2c3d4e5f6a7b',
|
||||
privateRouteTableId: 'rtb-0a1b2c3d4e5f6a7c',
|
||||
natGatewayId: 'nat-0a1b2c3d4e5f6a7b',
|
||||
eipAllocationId: 'eipalloc-0a1b2c3d4e5f6a7b',
|
||||
}),
|
||||
}
|
||||
|
||||
type FakeIpPermission = Ec2IpPermission // keep import used
|
||||
|
||||
const app = createEc2Router(fakeService)
|
||||
|
||||
describe('GET /instances', () => {
|
||||
test('returns instance array', async () => {
|
||||
const res = await app.request('/instances')
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as unknown[]
|
||||
expect(Array.isArray(body)).toBe(true)
|
||||
expect(body).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /instances/:id', () => {
|
||||
test('returns single instance with correct id', async () => {
|
||||
const res = await app.request('/instances/i-test1')
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {instanceId: string}
|
||||
expect(body.instanceId).toBe('i-test1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /instances', () => {
|
||||
test('creates instance and returns it', async () => {
|
||||
const res = await app.request('/instances', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({name: 'new-instance', imageId: 'ami-1', instanceType: 't3.micro'}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {instanceId: string; state: string}
|
||||
expect(body.instanceId).toBe('i-new1')
|
||||
expect(body.state).toBe('pending')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /instances/:id/start', () => {
|
||||
test('returns ok', async () => {
|
||||
const res = await app.request('/instances/i-test1/start', {method: 'POST'})
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json() as {ok: boolean}).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /instances/:id/terminate', () => {
|
||||
test('terminates instance and returns ok', async () => {
|
||||
const res = await app.request('/instances/i-test1/terminate', {method: 'POST'})
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json() as {ok: boolean}).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /amis/:imageId/deregister', () => {
|
||||
test('deregisters AMI and returns ok', async () => {
|
||||
const res = await app.request('/amis/ami-0a1b2c3d4e5f6a7b/deregister', {method: 'POST'})
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json() as {ok: boolean}).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /instances/:id/console', () => {
|
||||
test('returns console output', async () => {
|
||||
const res = await app.request('/instances/i-test1/console')
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {output: string}
|
||||
expect(body.output).toBe('boot log')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /security-groups', () => {
|
||||
test('returns array', async () => {
|
||||
const res = await app.request('/security-groups')
|
||||
expect(res.status).toBe(200)
|
||||
expect(Array.isArray(await res.json())).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /vpcs', () => {
|
||||
test('creates vpc and returns it', async () => {
|
||||
const res = await app.request('/vpcs', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({cidrBlock: '10.0.0.0/16'}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {vpcId: string}
|
||||
expect(body.vpcId).toBe('vpc-0a1b2c3d4e5f6a7b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /vpcs/:id', () => {
|
||||
test('returns ok', async () => {
|
||||
const res = await app.request('/vpcs/vpc-0a1b2c3d4e5f6a7b', {method: 'DELETE'})
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json() as {ok: boolean}).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /key-pairs', () => {
|
||||
test('creates key pair and returns PEM material', async () => {
|
||||
const res = await app.request('/key-pairs', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({name: 'my-key'}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {keyName: string; keyMaterial: string}
|
||||
expect(body.keyName).toBe('my-key')
|
||||
expect(body.keyMaterial).toBe('---PEM---')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /internet-gateways', () => {
|
||||
test('returns array with igw', async () => {
|
||||
const res = await app.request('/internet-gateways')
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {internetGatewayId: string}[]
|
||||
expect(body).toHaveLength(1)
|
||||
expect(body[0].internetGatewayId).toBe('igw-0a1b2c3d4e5f6a7b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /internet-gateways', () => {
|
||||
test('creates igw and returns it', async () => {
|
||||
const res = await app.request('/internet-gateways', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({name: 'my-igw'}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {internetGatewayId: string}
|
||||
expect(body.internetGatewayId).toBe('igw-0a1b2c3d4e5f6a7b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /internet-gateways/:igwId', () => {
|
||||
test('returns ok', async () => {
|
||||
const res = await app.request('/internet-gateways/igw-0a1b2c3d4e5f6a7b', {method: 'DELETE'})
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json() as {ok: boolean}).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /nat-gateways', () => {
|
||||
test('returns array with nat gateway', async () => {
|
||||
const res = await app.request('/nat-gateways')
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {natGatewayId: string}[]
|
||||
expect(body).toHaveLength(1)
|
||||
expect(body[0].natGatewayId).toBe('nat-0a1b2c3d4e5f6a7b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /nat-gateways', () => {
|
||||
test('creates nat gateway and returns it', async () => {
|
||||
const res = await app.request('/nat-gateways', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({name: 'my-nat', subnetId: 'subnet-0a1b2c3d4e5f0001'}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {natGatewayId: string}
|
||||
expect(body.natGatewayId).toBe('nat-0a1b2c3d4e5f6a7b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /route-tables', () => {
|
||||
test('returns array with route table', async () => {
|
||||
const res = await app.request('/route-tables')
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {routeTableId: string}[]
|
||||
expect(body).toHaveLength(1)
|
||||
expect(body[0].routeTableId).toBe('rtb-0a1b2c3d4e5f6a7b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /route-tables/:rtbId/routes', () => {
|
||||
test('creates route and returns ok', async () => {
|
||||
const res = await app.request('/route-tables/rtb-0a1b2c3d4e5f6a7b/routes', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({destinationCidrBlock: '0.0.0.0/0', gatewayId: 'igw-0a1b2c3d4e5f6a7b'}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json() as {ok: boolean}).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /route-tables/:rtbId/associations', () => {
|
||||
test('associates subnet and returns associationId', async () => {
|
||||
const res = await app.request('/route-tables/rtb-0a1b2c3d4e5f6a7b/associations', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({subnetId: 'subnet-0a1b2c3d4e5f0001'}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {associationId: string}
|
||||
expect(body.associationId).toBe('rtbassoc-0a1b2c3d4e5f6a7b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /elastic-ips', () => {
|
||||
test('returns array with eip', async () => {
|
||||
const res = await app.request('/elastic-ips')
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {allocationId: string}[]
|
||||
expect(body).toHaveLength(1)
|
||||
expect(body[0].allocationId).toBe('eipalloc-0a1b2c3d4e5f6a7b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /elastic-ips', () => {
|
||||
test('allocates eip and returns it', async () => {
|
||||
const res = await app.request('/elastic-ips', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {allocationId: string; publicIp: string}
|
||||
expect(body.allocationId).toBe('eipalloc-0a1b2c3d4e5f6a7b')
|
||||
expect(body.publicIp).toBe('54.0.0.1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /elastic-ips/:allocationId/release', () => {
|
||||
test('releases eip and returns ok', async () => {
|
||||
const res = await app.request('/elastic-ips/eipalloc-0a1b2c3d4e5f6a7b/release', {method: 'POST'})
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json() as {ok: boolean}).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /security-groups/:groupId/ingress', () => {
|
||||
test('authorizes ingress rule and returns ok', async () => {
|
||||
const res = await app.request('/security-groups/sg-0a1b2c3d/ingress', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({protocol: 'tcp', fromPort: 80, toPort: 80, cidr: '0.0.0.0/0'}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json() as {ok: boolean}).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /security-groups/:groupId/ingress', () => {
|
||||
test('revokes ingress rule and returns ok', async () => {
|
||||
const res = await app.request('/security-groups/sg-0a1b2c3d/ingress', {
|
||||
method: 'DELETE',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({protocol: 'tcp', fromPort: 80, toPort: 80, cidr: '0.0.0.0/0'}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json() as {ok: boolean}).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /security-groups/:groupId/egress', () => {
|
||||
test('authorizes egress rule and returns ok', async () => {
|
||||
const res = await app.request('/security-groups/sg-0a1b2c3d/egress', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({protocol: 'tcp', fromPort: 443, toPort: 443, cidr: '0.0.0.0/0'}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json() as {ok: boolean}).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /security-groups/:groupId/egress', () => {
|
||||
test('revokes egress rule and returns ok', async () => {
|
||||
const res = await app.request('/security-groups/sg-0a1b2c3d/egress', {
|
||||
method: 'DELETE',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({protocol: 'tcp', fromPort: 443, toPort: 443, cidr: '0.0.0.0/0'}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json() as {ok: boolean}).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /availability-zones', () => {
|
||||
test('returns availability zones array', async () => {
|
||||
const res = await app.request('/availability-zones')
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {zoneName: string}[]
|
||||
expect(body).toHaveLength(1)
|
||||
expect(body[0].zoneName).toBe('us-east-1a')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /instance-types', () => {
|
||||
test('returns instance types array', async () => {
|
||||
const res = await app.request('/instance-types')
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {instanceType: string; vcpu: number}[]
|
||||
expect(body).toHaveLength(1)
|
||||
expect(body[0].instanceType).toBe('t3.micro')
|
||||
expect(body[0].vcpu).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /subnets/:subnetId/attributes', () => {
|
||||
test('modifies subnet mapPublicIpOnLaunch and returns ok', async () => {
|
||||
const res = await app.request('/subnets/subnet-0a1b2c3d/attributes', {
|
||||
method: 'PUT',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({mapPublicIpOnLaunch: true}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json() as {ok: boolean}).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /vpcs/:vpcId/attributes', () => {
|
||||
test('returns vpc dns attributes', async () => {
|
||||
const res = await app.request('/vpcs/vpc-0a1b2c3d/attributes')
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {enableDnsHostnames: boolean; enableDnsSupport: boolean}
|
||||
expect(body).toHaveProperty('enableDnsHostnames', true)
|
||||
expect(body).toHaveProperty('enableDnsSupport', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /vpcs/:vpcId/attributes', () => {
|
||||
test('modifies vpc dns attribute and returns ok', async () => {
|
||||
const res = await app.request('/vpcs/vpc-0a1b2c3d/attributes', {
|
||||
method: 'PUT',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({attribute: 'enableDnsHostnames', value: false}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json() as {ok: boolean}).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /elastic-ips/:allocationId/associate', () => {
|
||||
test('associates eip to instance and returns associationId', async () => {
|
||||
const res = await app.request('/elastic-ips/eipalloc-0a1b2c3d4e5f6a7b/associate', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({instanceId: 'i-test1'}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {associationId: string}
|
||||
expect(body.associationId).toBe('eipassoc-test')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /elastic-ips/:allocationId/disassociate', () => {
|
||||
test('disassociates eip and returns ok', async () => {
|
||||
const res = await app.request('/elastic-ips/eipalloc-0a1b2c3d4e5f6a7b/disassociate', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({associationId: 'eipassoc-test'}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json() as {ok: boolean}).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Suppress unused-type lint warnings
|
||||
void ({} as FakeIpPermission)
|
||||
@@ -0,0 +1,482 @@
|
||||
import {Hono} from 'hono'
|
||||
import type {Context} from 'hono'
|
||||
import type {
|
||||
CreateAmiInput,
|
||||
CreateNatGatewayInput,
|
||||
CreateRouteInput,
|
||||
Ec2Tag,
|
||||
IpPermissionInput,
|
||||
RunInstanceInput,
|
||||
VpcWizardInput,
|
||||
} from '../services/ec2'
|
||||
import {ec2Service} from '../services/ec2'
|
||||
|
||||
type Ec2Service = typeof ec2Service
|
||||
|
||||
function handleError(c: Context, error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : 'Unknown error'
|
||||
return c.json({error: msg}, 400)
|
||||
}
|
||||
|
||||
export function createEc2Router(svc: Ec2Service = ec2Service) {
|
||||
const app = new Hono()
|
||||
|
||||
// Instances
|
||||
app.get('/instances', async (c) => {
|
||||
return c.json(await svc.listInstances())
|
||||
})
|
||||
|
||||
app.get('/instances/:instanceId', async (c) => {
|
||||
return c.json(await svc.describeInstance(c.req.param('instanceId')))
|
||||
})
|
||||
|
||||
app.post('/instances', async (c) => {
|
||||
try {
|
||||
const input = await c.req.json<RunInstanceInput>()
|
||||
return c.json(await svc.runInstance(input))
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/instances/:instanceId/start', async (c) => {
|
||||
try {
|
||||
await svc.startInstance(c.req.param('instanceId'))
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/instances/:instanceId/stop', async (c) => {
|
||||
try {
|
||||
await svc.stopInstance(c.req.param('instanceId'))
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/instances/:instanceId/reboot', async (c) => {
|
||||
try {
|
||||
await svc.rebootInstance(c.req.param('instanceId'))
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/instances/:instanceId/terminate', async (c) => {
|
||||
try {
|
||||
await svc.terminateInstance(c.req.param('instanceId'))
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.put('/instances/:instanceId/tags', async (c) => {
|
||||
try {
|
||||
const {toAdd, toRemove} = await c.req.json<{toAdd: Ec2Tag[]; toRemove: string[]}>()
|
||||
await svc.updateInstanceTags(c.req.param('instanceId'), toAdd, toRemove)
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/instances/:instanceId/image', async (c) => {
|
||||
try {
|
||||
const body = await c.req.json<Omit<CreateAmiInput, 'instanceId'>>()
|
||||
const result = await svc.createImage({...body, instanceId: c.req.param('instanceId')})
|
||||
return c.json(result)
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/instances/:instanceId/console', async (c) => {
|
||||
try {
|
||||
return c.json(await svc.getConsoleOutput(c.req.param('instanceId')))
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
// AMIs
|
||||
app.get('/amis', async (c) => {
|
||||
return c.json(await svc.listAmis())
|
||||
})
|
||||
|
||||
app.post('/amis/:imageId/deregister', async (c) => {
|
||||
try {
|
||||
await svc.deregisterImage(c.req.param('imageId'))
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
// Key pairs
|
||||
app.get('/key-pairs', async (c) => {
|
||||
return c.json(await svc.listKeyPairs())
|
||||
})
|
||||
|
||||
app.post('/key-pairs', async (c) => {
|
||||
try {
|
||||
const {name} = await c.req.json<{name: string}>()
|
||||
return c.json(await svc.createKeyPair(name))
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/key-pairs/:name', async (c) => {
|
||||
try {
|
||||
await svc.deleteKeyPair(c.req.param('name'))
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
// Security groups
|
||||
app.get('/security-groups', async (c) => {
|
||||
const vpcId = c.req.query('vpcId')
|
||||
return c.json(await svc.listSecurityGroups(vpcId))
|
||||
})
|
||||
|
||||
app.post('/security-groups', async (c) => {
|
||||
try {
|
||||
const {name, description, vpcId} = await c.req.json<{name: string; description: string; vpcId: string}>()
|
||||
return c.json(await svc.createSecurityGroup(name, description, vpcId))
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/security-groups/:groupId', async (c) => {
|
||||
try {
|
||||
await svc.deleteSecurityGroup(c.req.param('groupId'))
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/security-groups/:groupId/ingress', async (c) => {
|
||||
try {
|
||||
const permission = await c.req.json<IpPermissionInput>()
|
||||
await svc.authorizeSecurityGroupIngress(c.req.param('groupId'), permission)
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/security-groups/:groupId/ingress', async (c) => {
|
||||
try {
|
||||
const permission = await c.req.json<IpPermissionInput>()
|
||||
await svc.revokeSecurityGroupIngress(c.req.param('groupId'), permission)
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/security-groups/:groupId/egress', async (c) => {
|
||||
try {
|
||||
const permission = await c.req.json<IpPermissionInput>()
|
||||
await svc.authorizeSecurityGroupEgress(c.req.param('groupId'), permission)
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/security-groups/:groupId/egress', async (c) => {
|
||||
try {
|
||||
const permission = await c.req.json<IpPermissionInput>()
|
||||
await svc.revokeSecurityGroupEgress(c.req.param('groupId'), permission)
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
// Meta
|
||||
app.get('/availability-zones', async (c) => {
|
||||
return c.json(await svc.listAvailabilityZones())
|
||||
})
|
||||
|
||||
app.get('/instance-types', async (c) => {
|
||||
return c.json(await svc.listInstanceTypes())
|
||||
})
|
||||
|
||||
// VPCs
|
||||
app.get('/vpcs', async (c) => {
|
||||
return c.json(await svc.listVpcs())
|
||||
})
|
||||
|
||||
app.post('/vpcs', async (c) => {
|
||||
try {
|
||||
const {cidrBlock} = await c.req.json<{cidrBlock: string}>()
|
||||
return c.json(await svc.createVpc(cidrBlock))
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/vpcs/:vpcId', async (c) => {
|
||||
try {
|
||||
await svc.deleteVpc(c.req.param('vpcId'))
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/vpcs/:vpcId/attributes', async (c) => {
|
||||
try {
|
||||
return c.json(await svc.getVpcAttributes(c.req.param('vpcId')))
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.put('/vpcs/:vpcId/attributes', async (c) => {
|
||||
try {
|
||||
const {attribute, value} = await c.req.json<{attribute: 'enableDnsHostnames' | 'enableDnsSupport'; value: boolean}>()
|
||||
await svc.modifyVpcAttribute(c.req.param('vpcId'), attribute, value)
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
// Subnets
|
||||
app.get('/subnets', async (c) => {
|
||||
const vpcId = c.req.query('vpcId')
|
||||
return c.json(await svc.listSubnets(vpcId))
|
||||
})
|
||||
|
||||
app.post('/subnets', async (c) => {
|
||||
try {
|
||||
const {vpcId, cidrBlock, availabilityZone} = await c.req.json<{
|
||||
vpcId: string
|
||||
cidrBlock: string
|
||||
availabilityZone?: string
|
||||
}>()
|
||||
return c.json(await svc.createSubnet(vpcId, cidrBlock, availabilityZone))
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/subnets/:subnetId', async (c) => {
|
||||
try {
|
||||
await svc.deleteSubnet(c.req.param('subnetId'))
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.put('/subnets/:subnetId/attributes', async (c) => {
|
||||
try {
|
||||
const {mapPublicIpOnLaunch} = await c.req.json<{mapPublicIpOnLaunch: boolean}>()
|
||||
await svc.modifySubnetMapPublicIp(c.req.param('subnetId'), mapPublicIpOnLaunch)
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
// Internet Gateways
|
||||
app.get('/internet-gateways', async (c) => {
|
||||
return c.json(await svc.listInternetGateways())
|
||||
})
|
||||
|
||||
app.post('/internet-gateways', async (c) => {
|
||||
try {
|
||||
const body = await c.req.json<{name?: string}>().catch(() => ({} as {name?: string}))
|
||||
return c.json(await svc.createInternetGateway(body.name))
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/internet-gateways/:igwId/attach', async (c) => {
|
||||
try {
|
||||
const {vpcId} = await c.req.json<{vpcId: string}>()
|
||||
await svc.attachInternetGateway(c.req.param('igwId'), vpcId)
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/internet-gateways/:igwId/detach', async (c) => {
|
||||
try {
|
||||
const {vpcId} = await c.req.json<{vpcId: string}>()
|
||||
await svc.detachInternetGateway(c.req.param('igwId'), vpcId)
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/internet-gateways/:igwId', async (c) => {
|
||||
try {
|
||||
await svc.deleteInternetGateway(c.req.param('igwId'))
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
// NAT Gateways
|
||||
app.get('/nat-gateways', async (c) => {
|
||||
const vpcId = c.req.query('vpcId')
|
||||
return c.json(await svc.listNatGateways(vpcId))
|
||||
})
|
||||
|
||||
app.post('/nat-gateways', async (c) => {
|
||||
try {
|
||||
const input = await c.req.json<CreateNatGatewayInput>()
|
||||
return c.json(await svc.createNatGateway(input))
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/nat-gateways/:natId', async (c) => {
|
||||
try {
|
||||
await svc.deleteNatGateway(c.req.param('natId'))
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
// Route Tables
|
||||
app.get('/route-tables', async (c) => {
|
||||
const vpcId = c.req.query('vpcId')
|
||||
return c.json(await svc.listRouteTables(vpcId))
|
||||
})
|
||||
|
||||
app.post('/route-tables', async (c) => {
|
||||
try {
|
||||
const {vpcId, name} = await c.req.json<{vpcId: string; name?: string}>()
|
||||
return c.json(await svc.createRouteTable(vpcId, name))
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/route-tables/:rtbId', async (c) => {
|
||||
try {
|
||||
await svc.deleteRouteTable(c.req.param('rtbId'))
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/route-tables/:rtbId/routes', async (c) => {
|
||||
try {
|
||||
const input = await c.req.json<CreateRouteInput>()
|
||||
await svc.createRoute(c.req.param('rtbId'), input)
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/route-tables/:rtbId/routes', async (c) => {
|
||||
try {
|
||||
const cidr = c.req.query('cidr')
|
||||
if (!cidr) return handleError(c, new Error('cidr query parameter is required'))
|
||||
await svc.deleteRoute(c.req.param('rtbId'), cidr)
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/route-tables/:rtbId/associations', async (c) => {
|
||||
try {
|
||||
const {subnetId} = await c.req.json<{subnetId: string}>()
|
||||
const associationId = await svc.associateRouteTable(c.req.param('rtbId'), subnetId)
|
||||
return c.json({associationId})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/route-table-associations/:associationId', async (c) => {
|
||||
try {
|
||||
await svc.disassociateRouteTable(c.req.param('associationId'))
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
// Elastic IPs
|
||||
app.get('/elastic-ips', async (c) => {
|
||||
return c.json(await svc.listElasticIps())
|
||||
})
|
||||
|
||||
app.post('/elastic-ips', async (c) => {
|
||||
try {
|
||||
const body = await c.req.json<{name?: string}>().catch(() => ({} as {name?: string}))
|
||||
return c.json(await svc.allocateElasticIp(body.name))
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/elastic-ips/:allocationId/release', async (c) => {
|
||||
try {
|
||||
await svc.releaseElasticIp(c.req.param('allocationId'))
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/elastic-ips/:allocationId/associate', async (c) => {
|
||||
try {
|
||||
const {instanceId} = await c.req.json<{instanceId: string}>()
|
||||
return c.json(await svc.associateElasticIp(c.req.param('allocationId'), instanceId))
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/elastic-ips/:allocationId/disassociate', async (c) => {
|
||||
try {
|
||||
const {associationId} = await c.req.json<{associationId: string}>()
|
||||
await svc.disassociateElasticIp(associationId)
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
// VPC Wizard — creates VPC + IGW + subnets + route tables + optional NAT GW in one call
|
||||
app.post('/vpc-wizard', async (c) => {
|
||||
try {
|
||||
const input = await c.req.json<VpcWizardInput>()
|
||||
const result = await svc.createVpcWizard(input)
|
||||
return c.json(result)
|
||||
} catch (error) {
|
||||
return handleError(c, error)
|
||||
}
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
export default createEc2Router()
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Hono } from "hono";
|
||||
import { eksService } from "../services/eks";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/clusters", async (c) => {
|
||||
return c.json(await eksService.listClusters());
|
||||
});
|
||||
|
||||
app.get("/clusters/:name", async (c) => {
|
||||
return c.json(await eksService.describeCluster(c.req.param("name")));
|
||||
});
|
||||
|
||||
app.get("/clusters/:name/nodegroups", async (c) => {
|
||||
return c.json(await eksService.listNodegroups(c.req.param("name")));
|
||||
});
|
||||
|
||||
app.get("/clusters/:name/nodegroups/:nodegroup", async (c) => {
|
||||
return c.json(
|
||||
await eksService.describeNodegroup(
|
||||
c.req.param("name"),
|
||||
c.req.param("nodegroup"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/clusters/:name/nodegroups", async (c) => {
|
||||
const body = await c.req.json();
|
||||
return c.json(await eksService.createNodegroup(c.req.param("name"), body), 201);
|
||||
});
|
||||
|
||||
app.delete("/clusters/:name/nodegroups/:nodegroup", async (c) => {
|
||||
return c.json(
|
||||
await eksService.deleteNodegroup(
|
||||
c.req.param("name"),
|
||||
c.req.param("nodegroup"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/clusters/:name/fargate-profiles", async (c) => {
|
||||
return c.json(await eksService.listFargateProfiles(c.req.param("name")));
|
||||
});
|
||||
|
||||
app.get("/clusters/:name/fargate-profiles/:profile", async (c) => {
|
||||
return c.json(
|
||||
await eksService.describeFargateProfile(
|
||||
c.req.param("name"),
|
||||
c.req.param("profile"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/clusters/:name/fargate-profiles", async (c) => {
|
||||
const body = await c.req.json();
|
||||
return c.json(
|
||||
await eksService.createFargateProfile(c.req.param("name"), body),
|
||||
201,
|
||||
);
|
||||
});
|
||||
|
||||
app.delete("/clusters/:name/fargate-profiles/:profile", async (c) => {
|
||||
return c.json(
|
||||
await eksService.deleteFargateProfile(
|
||||
c.req.param("name"),
|
||||
c.req.param("profile"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,232 @@
|
||||
import {beforeEach, describe, expect, mock, test} from 'bun:test'
|
||||
|
||||
// The secretsmanager router imports the `secretsManager` singleton directly from
|
||||
// `../aws`, so we replace that module with a stub whose `send()` is driven per
|
||||
// test. `responder` returns the fake AWS SDK response and `lastCommand` captures
|
||||
// the command that was dispatched so we can assert on its input shape.
|
||||
let lastCommand: {constructor: {name: string}; input: Record<string, unknown>} | null = null
|
||||
let responder: (commandName: string, command: {input: Record<string, unknown>}) => unknown
|
||||
|
||||
mock.module('../aws', () => ({
|
||||
secretsManager: {
|
||||
async send(command: {constructor: {name: string}; input: Record<string, unknown>}) {
|
||||
lastCommand = command
|
||||
return responder(command.constructor.name, command)
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Imported after the mock is registered so the route binds to the stubbed client.
|
||||
const {default: app} = await import('./secretsmanager')
|
||||
|
||||
beforeEach(() => {
|
||||
lastCommand = null
|
||||
responder = () => ({})
|
||||
})
|
||||
|
||||
describe('GET /secrets', () => {
|
||||
test('follows NextToken pagination and maps each entry', async () => {
|
||||
const pages = [
|
||||
{
|
||||
SecretList: [{Name: 'a', ARN: 'arn:a', Tags: [{Key: 'env', Value: 'prod'}]}],
|
||||
NextToken: 'page2',
|
||||
},
|
||||
{SecretList: [{Name: 'b', ARN: 'arn:b'}], NextToken: undefined},
|
||||
]
|
||||
let call = 0
|
||||
responder = () => pages[call++]
|
||||
|
||||
const res = await app.request('/secrets')
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
|
||||
// Both pages are merged, proving the do/while followed NextToken.
|
||||
expect(call).toBe(2)
|
||||
expect(body).toHaveLength(2)
|
||||
expect(body[0]).toMatchObject({
|
||||
name: 'a',
|
||||
arn: 'arn:a',
|
||||
rotationEnabled: false,
|
||||
tags: [{key: 'env', value: 'prod'}],
|
||||
})
|
||||
expect(body[1]).toMatchObject({name: 'b', arn: 'arn:b', tags: []})
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /secret', () => {
|
||||
test('derives versionIds from VersionIdsToStages and maps tags', async () => {
|
||||
responder = () => ({
|
||||
Name: 'svc',
|
||||
ARN: 'arn:svc',
|
||||
Description: 'desc',
|
||||
VersionIdsToStages: {v1: ['AWSCURRENT'], v2: ['AWSPREVIOUS']},
|
||||
Tags: [{Key: 'team', Value: 'core'}],
|
||||
})
|
||||
|
||||
const res = await app.request('/secret?id=svc')
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.versionIds).toEqual(['v1', 'v2'])
|
||||
expect(body.tags).toEqual([{key: 'team', value: 'core'}])
|
||||
expect(lastCommand?.input.SecretId).toBe('svc')
|
||||
})
|
||||
|
||||
test('returns 400 when id is missing and never calls AWS', async () => {
|
||||
const res = await app.request('/secret')
|
||||
expect(res.status).toBe(400)
|
||||
expect(lastCommand).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /secret/value', () => {
|
||||
test('returns SecretString verbatim', async () => {
|
||||
responder = () => ({Name: 's', VersionId: 'v1', SecretString: '{"k":"v"}'})
|
||||
|
||||
const res = await app.request('/secret/value?id=s')
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.secretString).toBe('{"k":"v"}')
|
||||
expect(body.secretBinary).toBeUndefined()
|
||||
expect(body.versionId).toBe('v1')
|
||||
})
|
||||
|
||||
test('encodes SecretBinary as base64', async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3, 4])
|
||||
responder = () => ({Name: 's', SecretBinary: bytes})
|
||||
|
||||
const res = await app.request('/secret/value?id=s')
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.secretString).toBeUndefined()
|
||||
expect(body.secretBinary).toBe(Buffer.from(bytes).toString('base64'))
|
||||
})
|
||||
|
||||
test('marks the plaintext response as no-store', async () => {
|
||||
responder = () => ({Name: 's', SecretString: 'plain'})
|
||||
|
||||
const res = await app.request('/secret/value?id=s')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('cache-control')).toBe('no-store')
|
||||
})
|
||||
|
||||
test('returns 400 when id is missing and never calls AWS', async () => {
|
||||
const res = await app.request('/secret/value')
|
||||
expect(res.status).toBe(400)
|
||||
expect(lastCommand).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /secrets', () => {
|
||||
test('passes the create input through and echoes the result', async () => {
|
||||
responder = (_name, command) => ({Name: command.input.Name, ARN: 'arn:new', VersionId: 'v1'})
|
||||
|
||||
const res = await app.request('/secrets', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({name: 'newSecret', secretString: 'val', description: 'd'}),
|
||||
})
|
||||
const body = await res.json()
|
||||
|
||||
expect(lastCommand?.input).toMatchObject({Name: 'newSecret', SecretString: 'val', Description: 'd'})
|
||||
expect(body).toMatchObject({name: 'newSecret', arn: 'arn:new', versionId: 'v1'})
|
||||
})
|
||||
|
||||
test('returns 400 when name is missing and never calls AWS', async () => {
|
||||
const res = await app.request('/secrets', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({secretString: 'val'}),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(lastCommand).toBeNull()
|
||||
})
|
||||
|
||||
test('returns 400 when secretString is missing and never calls AWS', async () => {
|
||||
const res = await app.request('/secrets', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({name: 'newSecret'}),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(lastCommand).toBeNull()
|
||||
})
|
||||
|
||||
test('returns 400 on an invalid JSON body and never calls AWS', async () => {
|
||||
const res = await app.request('/secrets', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: 'not json',
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(lastCommand).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /secret/value', () => {
|
||||
test('writes a new SecretString version', async () => {
|
||||
responder = () => ({ARN: 'arn:x', VersionId: 'v2'})
|
||||
|
||||
const res = await app.request('/secret/value', {
|
||||
method: 'PUT',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({id: 'x', secretString: 'updated'}),
|
||||
})
|
||||
const body = await res.json()
|
||||
|
||||
expect(lastCommand?.input).toMatchObject({SecretId: 'x', SecretString: 'updated'})
|
||||
expect(body).toMatchObject({arn: 'arn:x', versionId: 'v2'})
|
||||
})
|
||||
|
||||
test('returns 400 when id is missing and never calls AWS', async () => {
|
||||
const res = await app.request('/secret/value', {
|
||||
method: 'PUT',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({secretString: 'updated'}),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(lastCommand).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /secret', () => {
|
||||
test('uses the 7-day recovery window by default', async () => {
|
||||
await app.request('/secret?id=x', {method: 'DELETE'})
|
||||
|
||||
expect(lastCommand?.input.RecoveryWindowInDays).toBe(7)
|
||||
expect(lastCommand?.input.ForceDeleteWithoutRecovery).toBeUndefined()
|
||||
})
|
||||
|
||||
test('force deletes without recovery when force=true', async () => {
|
||||
await app.request('/secret?id=x&force=true', {method: 'DELETE'})
|
||||
|
||||
expect(lastCommand?.input.ForceDeleteWithoutRecovery).toBe(true)
|
||||
expect(lastCommand?.input.RecoveryWindowInDays).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns 400 when id is missing and never calls AWS', async () => {
|
||||
const res = await app.request('/secret', {method: 'DELETE'})
|
||||
expect(res.status).toBe(400)
|
||||
expect(lastCommand).toBeNull()
|
||||
})
|
||||
|
||||
test('returns 400 when secretString is missing and never calls AWS', async () => {
|
||||
const res = await app.request('/secret/value', {
|
||||
method: 'PUT',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({id: 'x'}),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(lastCommand).toBeNull()
|
||||
})
|
||||
|
||||
test('returns 400 on an invalid JSON body and never calls AWS', async () => {
|
||||
const res = await app.request('/secret/value', {
|
||||
method: 'PUT',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: 'not json',
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(lastCommand).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import {Hono} from 'hono'
|
||||
import {
|
||||
CreateSecretCommand,
|
||||
DeleteSecretCommand,
|
||||
DescribeSecretCommand,
|
||||
GetSecretValueCommand,
|
||||
ListSecretsCommand,
|
||||
PutSecretValueCommand,
|
||||
type SecretListEntry,
|
||||
} from '@aws-sdk/client-secrets-manager'
|
||||
import {secretsManager} from '../aws'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
function iso(date?: Date): string | undefined {
|
||||
return date ? date.toISOString() : undefined
|
||||
}
|
||||
|
||||
app.get('/secrets', async (c) => {
|
||||
// ListSecrets is paginated; follow NextToken so environments with many
|
||||
// secrets do not silently return only the first page.
|
||||
const secrets: SecretListEntry[] = []
|
||||
let nextToken: string | undefined
|
||||
do {
|
||||
const res = await secretsManager.send(new ListSecretsCommand({NextToken: nextToken}))
|
||||
secrets.push(...(res.SecretList ?? []))
|
||||
nextToken = res.NextToken
|
||||
} while (nextToken)
|
||||
return c.json(secrets.map(s => ({
|
||||
name: s.Name ?? '',
|
||||
arn: s.ARN,
|
||||
description: s.Description,
|
||||
rotationEnabled: s.RotationEnabled ?? false,
|
||||
kmsKeyId: s.KmsKeyId,
|
||||
lastChangedDate: iso(s.LastChangedDate),
|
||||
lastAccessedDate: iso(s.LastAccessedDate),
|
||||
createdDate: iso(s.CreatedDate),
|
||||
tags: (s.Tags ?? []).map(t => ({key: t.Key ?? '', value: t.Value ?? ''})),
|
||||
})))
|
||||
})
|
||||
|
||||
app.get('/secret', async (c) => {
|
||||
const id = c.req.query('id') ?? ''
|
||||
if (!id) return c.json({error: 'id is required'}, 400)
|
||||
const res = await secretsManager.send(new DescribeSecretCommand({SecretId: id}))
|
||||
return c.json({
|
||||
name: res.Name ?? '',
|
||||
arn: res.ARN,
|
||||
description: res.Description,
|
||||
rotationEnabled: res.RotationEnabled ?? false,
|
||||
kmsKeyId: res.KmsKeyId,
|
||||
lastChangedDate: iso(res.LastChangedDate),
|
||||
lastAccessedDate: iso(res.LastAccessedDate),
|
||||
createdDate: iso(res.CreatedDate),
|
||||
deletedDate: iso(res.DeletedDate),
|
||||
versionIds: Object.keys(res.VersionIdsToStages ?? {}),
|
||||
tags: (res.Tags ?? []).map(t => ({key: t.Key ?? '', value: t.Value ?? ''})),
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/secret/value', async (c) => {
|
||||
const id = c.req.query('id') ?? ''
|
||||
if (!id) return c.json({error: 'id is required'}, 400)
|
||||
const res = await secretsManager.send(new GetSecretValueCommand({SecretId: id}))
|
||||
// The body carries plaintext secret material; mark it no-store so browsers,
|
||||
// disk caches and intermediary proxies never retain a copy.
|
||||
c.header('Cache-Control', 'no-store')
|
||||
return c.json({
|
||||
name: res.Name ?? '',
|
||||
arn: res.ARN,
|
||||
versionId: res.VersionId,
|
||||
secretString: res.SecretString,
|
||||
// SecretBinary is a Uint8Array; expose it as base64 so binary secrets survive JSON transport.
|
||||
secretBinary: res.SecretBinary ? Buffer.from(res.SecretBinary).toString('base64') : undefined,
|
||||
createdDate: iso(res.CreatedDate),
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/secrets', async (c) => {
|
||||
let body: {name?: string; description?: string; secretString?: string}
|
||||
try {
|
||||
body = await c.req.json()
|
||||
} catch {
|
||||
return c.json({error: 'invalid JSON body'}, 400)
|
||||
}
|
||||
const {name, description, secretString} = body
|
||||
if (!name) return c.json({error: 'name is required'}, 400)
|
||||
if (typeof secretString !== 'string') return c.json({error: 'secretString is required'}, 400)
|
||||
const res = await secretsManager.send(new CreateSecretCommand({
|
||||
Name: name,
|
||||
Description: description,
|
||||
SecretString: secretString,
|
||||
}))
|
||||
return c.json({name: res.Name ?? name, arn: res.ARN, versionId: res.VersionId})
|
||||
})
|
||||
|
||||
app.put('/secret/value', async (c) => {
|
||||
let body: {id?: string; secretString?: string}
|
||||
try {
|
||||
body = await c.req.json()
|
||||
} catch {
|
||||
return c.json({error: 'invalid JSON body'}, 400)
|
||||
}
|
||||
const {id, secretString} = body
|
||||
if (!id) return c.json({error: 'id is required'}, 400)
|
||||
if (typeof secretString !== 'string') return c.json({error: 'secretString is required'}, 400)
|
||||
const res = await secretsManager.send(new PutSecretValueCommand({
|
||||
SecretId: id,
|
||||
SecretString: secretString,
|
||||
}))
|
||||
return c.json({arn: res.ARN, versionId: res.VersionId})
|
||||
})
|
||||
|
||||
app.delete('/secret', async (c) => {
|
||||
const id = c.req.query('id') ?? ''
|
||||
if (!id) return c.json({error: 'id is required'}, 400)
|
||||
// Local development favours immediate removal over AWS's recovery window.
|
||||
const force = c.req.query('force') === 'true'
|
||||
await secretsManager.send(new DeleteSecretCommand({
|
||||
SecretId: id,
|
||||
...(force ? {ForceDeleteWithoutRecovery: true} : {RecoveryWindowInDays: 7}),
|
||||
}))
|
||||
return c.json({ok: true})
|
||||
})
|
||||
|
||||
export default app
|
||||
Reference in New Issue
Block a user