feat: MSK, MSK CONNECT and SES

This commit is contained in:
Zakaria
2026-10-05 11:06:57 -04:00
parent 2d8ff50a99
commit c0d3e5f402
84 changed files with 3513 additions and 195 deletions
+58
View File
@@ -963,6 +963,64 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
})
})
app.post('/:cloud/services/kinesis/resources/:id/records', 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 jsonBody<{partitionKey?: unknown; data?: unknown}>(c)
return c.json(await svc(c).putKinesisRecord(cloud, c.req.param('id'), {
partitionKey: typeof input.partitionKey === 'string' ? input.partitionKey : '',
data: typeof input.data === 'string' ? input.data : '',
}), 201)
})
})
app.get('/:cloud/services/kinesis/resources/:id/records', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).listKinesisRecords(cloud, c.req.param('id'))))
})
app.get('/:cloud/services/serverless/event-source-mappings', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).listLambdaEventSourceMappings(cloud, {
functionName: c.req.query('functionName'), eventSourceArn: c.req.query('eventSourceArn'),
})))
})
app.post('/:cloud/services/serverless/event-source-mappings', 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 jsonBody<{functionName?: unknown; eventSourceArn?: unknown; batchSize?: unknown; startingPosition?: unknown}>(c)
return c.json(await svc(c).createLambdaEventSourceMapping(cloud, {
functionName: typeof input.functionName === 'string' ? input.functionName : '',
eventSourceArn: typeof input.eventSourceArn === 'string' ? input.eventSourceArn : '',
batchSize: typeof input.batchSize === 'number' ? input.batchSize : undefined,
startingPosition: input.startingPosition === 'TRIM_HORIZON' || input.startingPosition === 'LATEST' ? input.startingPosition : undefined,
}), 201)
})
})
app.patch('/:cloud/services/serverless/event-source-mappings/:mappingId', 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 jsonBody<{batchSize?: unknown; enabled?: unknown}>(c)
return c.json(await svc(c).updateLambdaEventSourceMapping(cloud, c.req.param('mappingId'), {
batchSize: typeof input.batchSize === 'number' ? input.batchSize : undefined,
enabled: typeof input.enabled === 'boolean' ? input.enabled : undefined,
}))
})
})
app.delete('/:cloud/services/serverless/event-source-mappings/:mappingId', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => { await svc(c).deleteLambdaEventSourceMapping(cloud, c.req.param('mappingId')); return c.json({ok: true}) })
})
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)
+17
View File
@@ -75,6 +75,7 @@ const fakeService = {
createImage: async (_input: CreateAmiInput): Promise<{imageId: string}> => ({imageId: 'ami-0a1b2c3d4e5f6a7b'}),
deregisterImage: async () => {},
getConsoleOutput: async (instanceId: string): Promise<Ec2ConsoleOutput> => ({instanceId, output: 'boot log'}),
updateInstanceSecurityGroups: async () => {},
listAmis: async (): Promise<Ec2Image[]> => [],
listKeyPairs: async (): Promise<Ec2KeyPair[]> => [],
createKeyPair: async (name: string, _keyType: Ec2KeyPairType = 'rsa'): Promise<Ec2KeyPairMaterial> => ({keyPairId: 'key-0a1b2c3d4e5f6a7b', keyName: name, keyMaterial: '---PEM---'}),
@@ -201,6 +202,22 @@ describe('POST /instances/:id/terminate', () => {
})
})
describe('PUT /instances/:id/security-groups', () => {
test('replaces security groups', async () => {
let receivedGroupIds: string[] | undefined
const router = createEc2Router({...fakeService, updateInstanceSecurityGroups: async (_instanceId, groupIds) => {
receivedGroupIds = groupIds
}})
const res = await router.request('/instances/i-test1/security-groups', {
method: 'PUT',
headers: {'content-type': 'application/json'},
body: JSON.stringify({groupIds: ['sg-123']}),
})
expect(res.status).toBe(200)
expect(receivedGroupIds).toEqual(['sg-123'])
})
})
describe('POST /amis/:imageId/deregister', () => {
test('deregisters AMI and returns ok', async () => {
const res = await app.request('/amis/ami-0a1b2c3d4e5f6a7b/deregister', {method: 'POST'})
+41 -1
View File
@@ -15,6 +15,31 @@ import {ec2Service} from '../services/ec2'
type Ec2Service = typeof ec2Service
type CreateKeyPairInput = {name: string; keyType?: Ec2KeyPairType}
type Ec2DiagnosticResponse = {ec2?: {instances?: Array<{
instanceId?: string
sshHost?: string
sshPort?: number
containerId?: string
containerStatus?: 'running' | 'stopped' | 'unavailable'
}>}}
async function connectionDetails(instanceId: string) {
const endpoint = process.env.FLOCI_ENDPOINT
if (!endpoint) {
return undefined
}
try {
const response = await fetch(`${endpoint}/_floci/diagnose`)
if (!response.ok) {
return undefined
}
return (await response.json() as Ec2DiagnosticResponse).ec2?.instances
?.find((instance) => instance.instanceId === instanceId)
} catch {
return undefined
}
}
function isEc2KeyPairType(value: unknown): value is Ec2KeyPairType {
return value === 'rsa' || value === 'ed25519'
}
@@ -33,7 +58,9 @@ export function createEc2Router(svc: Ec2Service = ec2Service) {
})
app.get('/instances/:instanceId', async (c) => {
return c.json(await svc.describeInstance(c.req.param('instanceId')))
const instanceId = c.req.param('instanceId')
const [instance, connection] = await Promise.all([svc.describeInstance(instanceId), connectionDetails(instanceId)])
return c.json({...instance, connection})
})
app.post('/instances', async (c) => {
@@ -91,6 +118,19 @@ export function createEc2Router(svc: Ec2Service = ec2Service) {
}
})
app.put('/instances/:instanceId/security-groups', async (c) => {
try {
const {groupIds} = await c.req.json<{groupIds: string[]}>()
if (!Array.isArray(groupIds) || groupIds.length === 0 || groupIds.some((groupId) => !groupId)) {
return c.json({error: 'At least one security group is required'}, 400)
}
await svc.updateInstanceSecurityGroups(c.req.param('instanceId'), groupIds)
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'>>()
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test";
import { createMskRouter } from "./msk";
import { type mskService } from "../services/msk";
function createService() {
return {
listClusters: async () => [{ ClusterName: "orders" }],
createCluster: async () => ({ ClusterArn: "arn:cluster:orders" }),
listTopics: async () => [{ name: "orders", partitionCount: 1 }],
createTopic: async () => {},
deleteTopic: async () => {},
produceMessage: async () => [{ topicName: "orders", partition: 0, baseOffset: "0" }],
peekMessages: async () => [{ value: "hello" }],
consumerGroupLag: async () => [{ topic: "orders", totalLag: 0, partitions: [] }],
} as unknown as typeof mskService;
}
describe("msk routes", () => {
test("serves clusters", async () => {
const app = createMskRouter(createService());
const clusters = await app.request("/clusters");
expect(clusters.status).toBe(200);
expect(await clusters.json()).toEqual([{ ClusterName: "orders" }]);
});
test("serves topic, message, and consumer group operations through the API", async () => {
const app = createMskRouter(createService());
const topic = await app.request("/clusters/cluster-arn/topics");
expect(await topic.json()).toEqual([{ name: "orders", partitionCount: 1 }]);
const create = await app.request("/clusters/cluster-arn/topics", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ topic: "events" }),
});
expect(create.status).toBe(201);
const produced = await app.request("/clusters/cluster-arn/topics/orders/messages", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ value: "hello" }),
});
expect(produced.status).toBe(201);
const messages = await app.request("/clusters/cluster-arn/topics/orders/messages?limit=1");
expect(await messages.json()).toEqual([{ value: "hello" }]);
const lag = await app.request("/clusters/cluster-arn/consumer-groups/orders-workers/lag");
expect(await lag.json()).toEqual([{ topic: "orders", totalLag: 0, partitions: [] }]);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { Hono } from "hono";
import {
mskService,
type CreateMskClusterInput,
type CreateTopicInput,
type ProduceMessageInput,
} from "../services/msk";
type MskService = typeof mskService;
export function createMskRouter(service: MskService = mskService) {
const app = new Hono();
app.get("/clusters", async (c) => c.json(await service.listClusters()));
app.post("/clusters", async (c) => c.json(await service.createCluster(await c.req.json<CreateMskClusterInput>()), 201));
app.delete("/clusters/:clusterArn", async (c) => {
await service.deleteCluster(c.req.param("clusterArn"));
return c.json({ state: "DELETING" });
});
app.get("/clusters/:clusterArn/bootstrap-brokers", async (c) => c.json({bootstrapBrokers: await service.getBootstrapBrokers(c.req.param("clusterArn"))}));
app.get("/clusters/:clusterArn/topics", async (c) => c.json(await service.listTopics(c.req.param("clusterArn"))));
app.post("/clusters/:clusterArn/topics", async (c) => c.json(await service.createTopic(c.req.param("clusterArn"), await c.req.json<CreateTopicInput>()), 201));
app.delete("/clusters/:clusterArn/topics/:topic", async (c) => c.json(await service.deleteTopic(c.req.param("clusterArn"), c.req.param("topic"))));
app.post("/clusters/:clusterArn/topics/:topic/messages", async (c) => c.json(await service.produceMessage(c.req.param("clusterArn"), c.req.param("topic"), await c.req.json<ProduceMessageInput>()), 201));
app.get("/clusters/:clusterArn/topics/:topic/messages", async (c) => c.json(await service.peekMessages(c.req.param("clusterArn"), c.req.param("topic"), Number(c.req.query("limit") ?? 50))));
app.get("/clusters/:clusterArn/consumer-groups/:groupId/lag", async (c) => c.json(await service.consumerGroupLag(c.req.param("clusterArn"), c.req.param("groupId"), c.req.query("topic"))));
return app;
}
export default createMskRouter();
@@ -0,0 +1,20 @@
import {describe, expect, test} from "bun:test";
import {createMskConnectRouter} from "./mskConnect";
import {type mskConnectService} from "../services/mskConnect";
describe("MSK Connect routes", () => {
test("passes custom plugin and connector operations through the API", async () => {
const app = createMskConnectRouter({
listCustomPlugins: async () => [{name: "s3-sink"}], createCustomPlugin: async () => ({name: "s3-sink"}), listConnectors: async () => [{connectorName: "archive"}], createConnector: async () => ({connectorArn: "arn:archive"}), describeConnector: async () => ({connectorArn: "arn:archive", connectorState: "RUNNING"}), deleteConnector: async () => ({}), updateConnector: async () => ({connectorState: "UPDATING"}),
} as unknown as typeof mskConnectService);
expect((await app.request("/custom-plugins")).status).toBe(200);
const register = await app.request("/custom-plugins", {method: "POST", headers: {"content-type": "application/json"}, body: JSON.stringify({name: "s3-sink", contentType: "ZIP"})});
expect(register.status).toBe(201);
const create = await app.request("/connectors", {method: "POST", headers: {"content-type": "application/json"}, body: JSON.stringify({connectorName: "archive"})});
expect(create.status).toBe(201);
expect(await (await app.request("/connectors/arn%3Aarchive")).json()).toMatchObject({connectorState: "RUNNING"});
expect((await app.request("/connectors/arn%3Aarchive", {method: "PATCH", headers: {"content-type": "application/json"}, body: JSON.stringify({currentVersion: "1"})})).status).toBe(200);
expect((await app.request("/connectors/arn%3Aarchive", {method: "DELETE"})).status).toBe(200);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { Hono } from "hono";
import { mskConnectService } from "../services/mskConnect";
type MskConnectService = typeof mskConnectService;
export function createMskConnectRouter(service: MskConnectService = mskConnectService) {
const app = new Hono();
app.get("/custom-plugins", async (c) => c.json(await service.listCustomPlugins()));
app.post("/custom-plugins", async (c) => c.json(await service.createCustomPlugin(await c.req.json()), 201));
app.delete("/custom-plugins/:customPluginArn", async (c) => c.json(await service.deleteCustomPlugin(c.req.param("customPluginArn"))));
app.get("/connectors", async (c) => c.json(await service.listConnectors()));
app.post("/connectors", async (c) => c.json(await service.createConnector(await c.req.json()), 201));
app.get("/connectors/:connectorArn", async (c) => c.json(await service.describeConnector(c.req.param("connectorArn"))));
app.delete("/connectors/:connectorArn", async (c) => c.json(await service.deleteConnector(c.req.param("connectorArn"))));
app.patch("/connectors/:connectorArn", async (c) => c.json(await service.updateConnector({
...(await c.req.json()),
connectorArn: c.req.param("connectorArn"),
})));
return app;
}
export default createMskConnectRouter();
+55
View File
@@ -0,0 +1,55 @@
import {Hono} from "hono";
import {
sesService,
type RawEmailInput,
} from "../services/ses";
import type {
CreateConfigurationSetCommandInput,
CreateConfigurationSetEventDestinationCommandInput,
CreateEmailIdentityCommandInput,
CreateEmailTemplateCommandInput,
PutSuppressedDestinationCommandInput,
SendEmailCommandInput,
TestRenderEmailTemplateCommandInput,
UpdateEmailTemplateCommandInput,
} from "@aws-sdk/client-sesv2";
type SesService = typeof sesService;
export function createSesRouter(service: SesService = sesService) {
const app = new Hono();
app.get("/identities", async (c) => c.json(await service.listIdentities()));
app.post("/identities", async (c) => c.json(await service.createIdentity(await c.req.json<CreateEmailIdentityCommandInput>()), 201));
app.delete("/identities/:identity", async (c) => c.json(await service.deleteIdentity(c.req.param("identity"))));
app.post("/emails/simple", async (c) => c.json(await service.sendSimple(await c.req.json<SendEmailCommandInput>()), 201));
app.post("/emails/raw", async (c) => c.json(await service.sendRaw(await c.req.json<RawEmailInput>()), 201));
app.post("/emails/template", async (c) => c.json(await service.sendTemplate(await c.req.json<SendEmailCommandInput>()), 201));
app.get("/suppressions", async (c) => c.json(await service.listSuppressedDestinations()));
app.post("/suppressions", async (c) => c.json(await service.createSuppression(await c.req.json<PutSuppressedDestinationCommandInput>()), 201));
app.delete("/suppressions/:emailAddress", async (c) => c.json(await service.deleteSuppression(c.req.param("emailAddress"))));
app.get("/templates", async (c) => c.json(await service.listTemplates()));
app.get("/templates/:templateName", async (c) => c.json(await service.getTemplate(c.req.param("templateName"))));
app.post("/templates", async (c) => c.json(await service.createTemplate(await c.req.json<CreateEmailTemplateCommandInput>()), 201));
app.put("/templates/:templateName", async (c) => c.json(await service.updateTemplate(c.req.param("templateName"), await c.req.json<Omit<UpdateEmailTemplateCommandInput, "TemplateName">>())));
app.delete("/templates/:templateName", async (c) => c.json(await service.deleteTemplate(c.req.param("templateName"))));
app.post("/templates/test-render", async (c) => c.json(await service.testRenderTemplate(await c.req.json<TestRenderEmailTemplateCommandInput>())));
app.get("/configuration-sets", async (c) => c.json(await service.listConfigurationSets()));
app.post("/configuration-sets", async (c) => c.json(await service.createConfigurationSet(await c.req.json<CreateConfigurationSetCommandInput>()), 201));
app.delete("/configuration-sets/:configurationSetName", async (c) => c.json(await service.deleteConfigurationSet(c.req.param("configurationSetName"))));
app.get("/configuration-sets/:configurationSetName/event-destinations", async (c) => c.json(await service.listEventDestinations(c.req.param("configurationSetName"))));
app.post("/configuration-sets/:configurationSetName/event-destinations", async (c) => c.json(await service.createEventDestination(c.req.param("configurationSetName"), await c.req.json<Omit<CreateConfigurationSetEventDestinationCommandInput, "ConfigurationSetName">>()), 201));
app.delete("/configuration-sets/:configurationSetName/event-destinations/:eventDestinationName", async (c) => c.json(await service.deleteEventDestination(c.req.param("configurationSetName"), c.req.param("eventDestinationName"))));
app.get("/inventory/sns-topics", async (c) => c.json(await service.listSnsTopics()));
app.get("/inventory/event-buses", async (c) => c.json(await service.listEventBuses()));
app.get("/inventory/cloudwatch-metrics", async (c) => c.json(await service.listSesMetrics()));
return app;
}
export default createSesRouter();