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
+6
View File
@@ -13,6 +13,7 @@
"@aws-sdk/client-api-gateway": "^3.1076.0",
"@aws-sdk/client-appconfig": "^3.1076.0",
"@aws-sdk/client-cloudformation": "^3.1111.0",
"@aws-sdk/client-cloudwatch": "^3.1076.0",
"@aws-sdk/client-cloudwatch-logs": "^3.1096.0",
"@aws-sdk/client-codebuild": "^3.1076.0",
"@aws-sdk/client-codedeploy": "^3.1144.0",
@@ -25,12 +26,16 @@
"@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0",
"@aws-sdk/client-eventbridge": "^3.1076.0",
"@aws-sdk/client-iam": "^3.1076.0",
"@aws-sdk/client-kafka": "^3.1146.0",
"@aws-sdk/client-kafkaconnect": "^3.1146.0",
"@aws-sdk/client-kinesis": "^3.1076.0",
"@aws-sdk/client-kms": "^3.1096.0",
"@aws-sdk/client-lambda": "^3.1076.0",
"@aws-sdk/client-rds": "^3.1076.0",
"@aws-sdk/client-rds-data": "^3.1076.0",
"@aws-sdk/client-s3": "^3.1076.0",
"@aws-sdk/client-secrets-manager": "^3.1076.0",
"@aws-sdk/client-sesv2": "^3.1076.0",
"@aws-sdk/client-sfn": "^3.1096.0",
"@aws-sdk/client-sns": "^3.1140.0",
"@aws-sdk/client-sqs": "^3.1076.0",
@@ -38,6 +43,7 @@
"@aws-sdk/util-dynamodb": "^3.996.7",
"dotenv": "^17.4.2",
"hono": "^4.12.27",
"kafkajs": "^2.2.4",
"mssql": "^12.7.0",
"pg": "^8.22.0"
},
@@ -24,7 +24,7 @@ describe('AwsCodeBuildAdapter', () => {
test('creates a privileged Docker-to-ECR project with no artifacts', async () => {
const {client, sent} = stub((command) => command instanceof CreateProjectCommand ? {project} : {})
await new AwsCodeBuildAdapter(client).create({values: {name: 'orders', serviceRole: project.serviceRole, buildspec: 'version: 0.2', ecrRepository: 'orders', dockerPrivilegedMode: 'enabled'}})
expect((sent[0] as CreateProjectCommand).input).toMatchObject({name: 'orders', serviceRole: project.serviceRole, source: {type: 'NO_SOURCE', buildspec: 'version: 0.2'}, artifacts: {type: 'NO_ARTIFACTS'}, environment: {type: 'LINUX_CONTAINER', image: 'aws/codebuild/standard:7.0', computeType: 'BUILD_GENERAL1_SMALL', privilegedMode: true, environmentVariables: [{name: 'ECR_REPOSITORY', value: 'orders'}]}})
expect((sent[0] as CreateProjectCommand).input).toMatchObject({name: 'orders', serviceRole: project.serviceRole, source: {type: 'NO_SOURCE', buildspec: 'version: 0.2'}, artifacts: {type: 'NO_ARTIFACTS'}, environment: {type: 'LINUX_CONTAINER', image: 'node:20-bookworm', computeType: 'BUILD_GENERAL1_SMALL', privilegedMode: true, environmentVariables: [{name: 'ECR_REPOSITORY', value: 'orders'}]}})
})
test('creates S3 source and artifact contracts with standard CodeBuild fields', async () => {
@@ -47,7 +47,7 @@ export class AwsCodeBuildAdapter implements CloudServiceAdapter {
async create(input: CreateResourceInput): Promise<CloudResource> {
const name = required(input.values.name, 'name')
const serviceRole = required(input.values.serviceRole, 'serviceRole')
const image = optional(input.values.environmentImage) ?? 'aws/codebuild/standard:7.0'
const image = optional(input.values.environmentImage) ?? 'node:20-bookworm'
const computeType = optional(input.values.computeType) ?? ComputeType.BUILD_GENERAL1_SMALL
if (computeType !== ComputeType.BUILD_GENERAL1_SMALL) throw new ValidationError('computeType must be BUILD_GENERAL1_SMALL')
const sourceType = supportedType(input.values.sourceType, 'sourceType', 'NO_SOURCE')
@@ -1,9 +1,49 @@
import {describe, expect, test} from 'bun:test'
import {CreateDeploymentCommand, GetDeploymentCommand, StopDeploymentCommand, type CodeDeployClient} from '@aws-sdk/client-codedeploy'
import {CreateApplicationCommand, CreateDeploymentCommand, CreateDeploymentGroupCommand, GetDeploymentCommand, GetDeploymentGroupCommand, ListApplicationsCommand, StopDeploymentCommand, type CodeDeployClient} from '@aws-sdk/client-codedeploy'
import type {LambdaClient} from '@aws-sdk/client-lambda'
import {AwsCodeDeployAdapter} from './AwsCodeDeployAdapter'
describe('AwsCodeDeployAdapter', () => {
test('creates an ECS application and blue-green deployment group with native CodeDeploy inputs', async () => {
const sent: object[] = []
const client = {async send(command: object) {
sent.push(command)
if (command instanceof ListApplicationsCommand) return {applications: []}
if (command instanceof GetDeploymentGroupCommand) return {deploymentGroupInfo: {deploymentGroupName: 'production', computePlatform: 'ECS', ecsServices: [{clusterName: 'orders', serviceName: 'api'}]}}
return {}
}} as unknown as CodeDeployClient
const adapter = new AwsCodeDeployAdapter(client, {} as LambdaClient)
await adapter.create({values: {applicationName: 'orders', deploymentGroupName: 'production', computePlatform: 'ECS', serviceRoleArn: 'arn:aws:iam::000000000000:role/CodeDeployRole', clusterName: 'orders', serviceName: 'api', blueTargetGroupName: 'orders-blue', greenTargetGroupName: 'orders-green', productionListenerArn: 'arn:aws:elasticloadbalancing:us-east-1:000000000000:listener/app/orders/123/456', deploymentConfig: 'Canary10Percent5Minutes', rollbackOnFailure: false}})
expect((sent.find((item) => item instanceof CreateApplicationCommand) as CreateApplicationCommand).input).toEqual({applicationName: 'orders', computePlatform: 'ECS'})
expect((sent.find((item) => item instanceof CreateDeploymentGroupCommand) as CreateDeploymentGroupCommand).input).toEqual({
applicationName: 'orders',
deploymentGroupName: 'production',
serviceRoleArn: 'arn:aws:iam::000000000000:role/CodeDeployRole',
deploymentConfigName: 'CodeDeployDefault.ECSCanary10Percent5Minutes',
deploymentStyle: {deploymentType: 'BLUE_GREEN', deploymentOption: 'WITH_TRAFFIC_CONTROL'},
autoRollbackConfiguration: {enabled: false, events: ['DEPLOYMENT_FAILURE']},
ecsServices: [{clusterName: 'orders', serviceName: 'api'}],
loadBalancerInfo: {targetGroupPairInfoList: [{targetGroups: [{name: 'orders-blue'}, {name: 'orders-green'}], prodTrafficRoute: {listenerArns: ['arn:aws:elasticloadbalancing:us-east-1:000000000000:listener/app/orders/123/456']}}]},
})
})
test('preserves Lambda application and deployment group defaults', async () => {
const sent: object[] = []
const client = {async send(command: object) {
sent.push(command)
if (command instanceof ListApplicationsCommand) return {applications: []}
if (command instanceof GetDeploymentGroupCommand) return {deploymentGroupInfo: {deploymentGroupName: 'production', computePlatform: 'Lambda'}}
return {}
}} as unknown as CodeDeployClient
await new AwsCodeDeployAdapter(client, {} as LambdaClient).create({values: {applicationName: 'orders', deploymentGroupName: 'production', serviceRoleArn: 'arn:aws:iam::000000000000:role/CodeDeployRole'}})
expect((sent.find((item) => item instanceof CreateApplicationCommand) as CreateApplicationCommand).input.computePlatform).toBe('Lambda')
expect((sent.find((item) => item instanceof CreateDeploymentGroupCommand) as CreateDeploymentGroupCommand).input).toEqual({applicationName: 'orders', deploymentGroupName: 'production', serviceRoleArn: 'arn:aws:iam::000000000000:role/CodeDeployRole', deploymentConfigName: 'CodeDeployDefault.LambdaAllAtOnce', deploymentStyle: {deploymentType: 'BLUE_GREEN', deploymentOption: 'WITH_TRAFFIC_CONTROL'}, autoRollbackConfiguration: {enabled: true, events: ['DEPLOYMENT_FAILURE']}})
})
test('creates a native Lambda AppSpec revision with traffic hooks', async () => {
const sent: object[] = []
const client = {async send(command: object) {
@@ -47,15 +47,30 @@ export class AwsCodeDeployAdapter implements CloudServiceAdapter {
async create(input: CreateResourceInput): Promise<CloudResource> {
const applicationName = required(input.values.applicationName, 'applicationName')
const deploymentGroupName = required(input.values.deploymentGroupName, 'deploymentGroupName')
const computePlatform = optional(input.values.computePlatform) ?? 'Lambda'
if (computePlatform !== 'Lambda' && computePlatform !== 'ECS') throw new ValidationError('computePlatform must be Lambda or ECS')
const applications = await this.client.send(new ListApplicationsCommand({}))
if (!(applications.applications ?? []).includes(applicationName)) await this.client.send(new CreateApplicationCommand({applicationName, computePlatform: 'Lambda'}))
if (!(applications.applications ?? []).includes(applicationName)) await this.client.send(new CreateApplicationCommand({applicationName, computePlatform}))
await this.client.send(new CreateDeploymentGroupCommand({
applicationName,
deploymentGroupName,
serviceRoleArn: required(input.values.serviceRoleArn, 'serviceRoleArn'),
deploymentConfigName: deploymentConfig(input.values.deploymentConfig),
deploymentConfigName: deploymentConfig(input.values.deploymentConfig, computePlatform),
deploymentStyle: {deploymentType: 'BLUE_GREEN', deploymentOption: 'WITH_TRAFFIC_CONTROL'},
autoRollbackConfiguration: {enabled: input.values.rollbackOnFailure !== false, events: ['DEPLOYMENT_FAILURE']},
...(computePlatform === 'ECS' ? {
ecsServices: [{
clusterName: required(input.values.clusterName, 'clusterName'),
serviceName: required(input.values.serviceName, 'serviceName'),
}],
loadBalancerInfo: {targetGroupPairInfoList: [{
targetGroups: [
{name: required(input.values.blueTargetGroupName, 'blueTargetGroupName')},
{name: required(input.values.greenTargetGroupName, 'greenTargetGroupName')},
],
prodTrafficRoute: {listenerArns: [required(input.values.productionListenerArn, 'productionListenerArn')]},
}]},
} : {}),
}))
return this.groupResource(applicationName, deploymentGroupName)
}
@@ -117,14 +132,15 @@ export class AwsCodeDeployAdapter implements CloudServiceAdapter {
const group = response.deploymentGroupInfo
if (!group?.deploymentGroupName) throw new RuntimeError(`CodeDeploy deployment group ${deploymentGroupName} was not found`)
const configuration = group.deploymentConfigName
return {id: `${applicationName}/${deploymentGroupName}`, name: deploymentGroupName, cloud: 'aws', service: 'codedeploy', type: 'codedeploy-deployment-group', region: regionFromArn(group.deploymentGroupId), createdAt: null, status: null, metadata: {applicationName, deploymentGroupName, deploymentGroupArn: group.deploymentGroupId, serviceRoleArn: group.serviceRoleArn, deploymentConfigName: configuration, rollbackOnFailure: group.autoRollbackConfiguration?.enabled ?? false, ecsServices: (group.ecsServices ?? []).map((service) => ({clusterName: service.clusterName, serviceName: service.serviceName})), updatedAt: group.lastSuccessfulDeployment?.createTime?.toISOString()}}
const targetGroupPair = group.loadBalancerInfo?.targetGroupPairInfoList?.[0]
return {id: `${applicationName}/${deploymentGroupName}`, name: deploymentGroupName, cloud: 'aws', service: 'codedeploy', type: 'codedeploy-deployment-group', region: regionFromArn(group.deploymentGroupId), createdAt: null, status: null, metadata: {applicationName, deploymentGroupName, deploymentGroupArn: group.deploymentGroupId, computePlatform: group.computePlatform ?? ((group.ecsServices ?? []).length ? 'ECS' : 'Lambda'), serviceRoleArn: group.serviceRoleArn, deploymentConfigName: configuration, rollbackOnFailure: group.autoRollbackConfiguration?.enabled ?? false, ecsServices: (group.ecsServices ?? []).map((service) => ({clusterName: service.clusterName, serviceName: service.serviceName})), targetGroupNames: (targetGroupPair?.targetGroups ?? []).map((targetGroup) => targetGroup.name).filter(Boolean), productionListenerArns: targetGroupPair?.prodTrafficRoute?.listenerArns ?? [], updatedAt: group.lastSuccessfulDeployment?.createTime?.toISOString()}}
}
}
function required(value: unknown, field: string): string { const result = optional(value); if (!result) throw new ValidationError(`${field} is required`); return result }
function optional(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined }
function splitId(id: string): [string, string] { const separator = id.indexOf('/'); if (separator <= 0 || separator === id.length - 1) throw new ValidationError('Deployment group id must be applicationName/deploymentGroupName'); return [id.slice(0, separator), id.slice(separator + 1)] }
function deploymentConfig(value: unknown): string { const choice = optional(value) ?? 'AllAtOnce'; return `CodeDeployDefault.Lambda${choice}` }
function deploymentConfig(value: unknown, computePlatform: 'Lambda' | 'ECS'): string { const choice = optional(value) ?? 'AllAtOnce'; return `CodeDeployDefault.${computePlatform}${choice}` }
function regionFromArn(arn?: string): string | null { return arn?.split(':')[3] || null }
function hooksFor(input: {beforeAllowTrafficHook?: string; afterAllowTrafficHook?: string}): Array<Record<string, string>> { const hooks: Array<Record<string, string>> = []; if (input.beforeAllowTrafficHook) hooks.push({BeforeAllowTraffic: input.beforeAllowTrafficHook}); if (input.afterAllowTrafficHook) hooks.push({AfterAllowTraffic: input.afterAllowTrafficHook}); return hooks }
function appSpecInfo(content?: string): {functionName: string | null; alias: string | null; currentVersion: string | null; targetVersion: string | null} { try { const parsed = JSON.parse(content ?? '{}') as {Resources?: Array<Record<string, {Properties?: {Name?: string; Alias?: string; CurrentVersion?: string; TargetVersion?: string}}>>}; const properties = parsed.Resources?.[0] && Object.values(parsed.Resources[0])[0]?.Properties; return {functionName: properties?.Name ?? null, alias: properties?.Alias ?? null, currentVersion: properties?.CurrentVersion ?? null, targetVersion: properties?.TargetVersion ?? null} } catch { return {functionName: null, alias: null, currentVersion: null, targetVersion: null} } }
@@ -1,5 +1,5 @@
import {describe, expect, test} from 'bun:test'
import {CreateServiceCommand, DeleteServiceCommand, DescribeServicesCommand, DescribeTasksCommand, ListClustersCommand, ListServicesCommand, ListTasksCommand, type ECSClient} from '@aws-sdk/client-ecs'
import {CreateClusterCommand, CreateServiceCommand, DeleteServiceCommand, DescribeClustersCommand, DescribeServicesCommand, DescribeTaskDefinitionCommand, DescribeTasksCommand, ListClustersCommand, ListServicesCommand, ListTaskDefinitionsCommand, ListTasksCommand, RegisterTaskDefinitionCommand, type ECSClient} from '@aws-sdk/client-ecs'
import {DescribeLoadBalancersCommand, DescribeTargetGroupsCommand, DescribeTargetHealthCommand, type ElasticLoadBalancingV2Client} from '@aws-sdk/client-elastic-load-balancing-v2'
import {AwsEcsAdapter} from './AwsEcsAdapter'
import {AwsFargateAdapter} from './AwsFargateAdapter'
@@ -44,13 +44,59 @@ describe('AwsEcsAdapter', () => {
const elb = {async send() { return {} }} as unknown as ElasticLoadBalancingV2Client
const adapter = new AwsEcsAdapter(ecs, elb)
await adapter.create({values: {cluster, serviceName: 'orders', taskDefinition: 'orders:1', launchType: 'FARGATE', subnets: 'subnet-1, subnet-2', securityGroups: 'sg-1', assignPublicIp: 'ENABLED'}})
await adapter.create({values: {cluster, serviceName: 'orders', taskDefinition: 'orders:1', launchType: 'FARGATE', deploymentController: 'CODE_DEPLOY', subnets: 'subnet-1, subnet-2', securityGroups: 'sg-1', assignPublicIp: 'ENABLED', targetGroupArn: 'tg-1', containerName: 'app', containerPort: '8080'}})
await adapter.delete(`${cluster}|orders`)
expect((sent[0] as CreateServiceCommand).input).toMatchObject({cluster, serviceName: 'orders', launchType: 'FARGATE', networkConfiguration: {awsvpcConfiguration: {subnets: ['subnet-1', 'subnet-2'], securityGroups: ['sg-1'], assignPublicIp: 'ENABLED'}}})
expect((sent[0] as CreateServiceCommand).input).toMatchObject({cluster, serviceName: 'orders', launchType: 'FARGATE', deploymentController: {type: 'CODE_DEPLOY'}, networkConfiguration: {awsvpcConfiguration: {subnets: ['subnet-1', 'subnet-2'], securityGroups: ['sg-1'], assignPublicIp: 'ENABLED'}}, loadBalancers: [{targetGroupArn: 'tg-1', containerName: 'app', containerPort: 8080}]})
expect((sent.at(-1) as DeleteServiceCommand).input).toEqual({cluster, service: 'orders', force: true})
})
test('creates and lists clusters through ECS APIs', async () => {
const sent: object[] = []
const clusterArn = 'arn:aws:ecs:us-east-1:000000000000:cluster/orders'
const ecs = {async send(command: object) {
sent.push(command)
if (command instanceof CreateClusterCommand) return {cluster: {clusterName: 'orders', clusterArn, status: 'ACTIVE'}}
if (command instanceof ListClustersCommand) return {clusterArns: [clusterArn]}
if (command instanceof DescribeClustersCommand) return {clusters: [{clusterName: 'orders', clusterArn, status: 'ACTIVE', runningTasksCount: 2}]}
if (command instanceof ListServicesCommand || command instanceof ListTaskDefinitionsCommand) return {}
return {}
}} as unknown as ECSClient
const adapter = new AwsEcsAdapter(ecs, {async send() { return {} }} as unknown as ElasticLoadBalancingV2Client)
const created = await adapter.create({values: {resourceKind: 'cluster', clusterName: 'orders'}})
const resources = await adapter.list()
expect(sent[0]).toBeInstanceOf(CreateClusterCommand)
expect((sent[0] as CreateClusterCommand).input).toEqual({clusterName: 'orders'})
expect(created).toMatchObject({id: `cluster:${clusterArn}`, type: 'ecs-cluster', name: 'orders'})
expect(resources).toContainEqual(expect.objectContaining({type: 'ecs-cluster', name: 'orders', metadata: expect.objectContaining({runningTasksCount: 2})}))
})
test('registers and lists only awsvpc Fargate task definitions', async () => {
const sent: object[] = []
const fargateArn = 'arn:aws:ecs:us-east-1:000000000000:task-definition/orders:1'
const ec2Arn = 'arn:aws:ecs:us-east-1:000000000000:task-definition/worker:1'
const fargate = {taskDefinitionArn: fargateArn, family: 'orders', revision: 1, status: 'ACTIVE', cpu: '256', memory: '512', networkMode: 'awsvpc', requiresCompatibilities: ['FARGATE'], containerDefinitions: [{name: 'web', image: 'orders:latest', portMappings: [{containerPort: 8080}]}]}
const ecs = {async send(command: object) {
sent.push(command)
if (command instanceof RegisterTaskDefinitionCommand) return {taskDefinition: fargate}
if (command instanceof ListClustersCommand) return {}
if (command instanceof ListTaskDefinitionsCommand) return {taskDefinitionArns: [fargateArn, ec2Arn]}
if (command instanceof DescribeTaskDefinitionCommand) return {taskDefinition: command.input.taskDefinition === fargateArn ? fargate : {taskDefinitionArn: ec2Arn, family: 'worker', networkMode: 'bridge', requiresCompatibilities: ['EC2']}}
return {}
}} as unknown as ECSClient
const adapter = new AwsEcsAdapter(ecs, {async send() { return {} }} as unknown as ElasticLoadBalancingV2Client)
const created = await adapter.create({values: {resourceKind: 'taskDefinition', family: 'orders', image: 'orders:latest', containerName: 'web', containerPort: '8080', cpu: '256', memory: '512'}})
const resources = await adapter.list()
expect((sent[0] as RegisterTaskDefinitionCommand).input).toEqual({family: 'orders', cpu: '256', memory: '512', networkMode: 'awsvpc', requiresCompatibilities: ['FARGATE'], containerDefinitions: [{name: 'web', image: 'orders:latest', essential: true, portMappings: [{containerPort: 8080, protocol: 'tcp'}]}]})
expect(created).toMatchObject({type: 'ecs-task-definition', name: 'orders:1', metadata: {image: 'orders:latest', containerPort: 8080}})
expect(resources).toHaveLength(1)
expect(resources[0]).toMatchObject({id: `task-definition:${fargateArn}`, type: 'ecs-task-definition'})
})
test('does not present stale inactive counts as running', async () => {
const ecs = {async send(command: object) {
if (command instanceof DescribeServicesCommand) return {services: [{serviceName: 'orders', status: 'INACTIVE', desiredCount: 2, runningCount: 2, pendingCount: 1, deployments: [{desiredCount: 2, runningCount: 2}]}]}
+208 -31
View File
@@ -1,9 +1,31 @@
import {CreateServiceCommand, DeleteServiceCommand, DescribeServicesCommand, DescribeTasksCommand, ListClustersCommand, ListServicesCommand, ListTasksCommand, type ECSClient, type Service} from '@aws-sdk/client-ecs'
import {
CreateClusterCommand,
CreateServiceCommand,
DeleteClusterCommand,
DeleteServiceCommand,
DeregisterTaskDefinitionCommand,
DescribeClustersCommand,
DescribeServicesCommand,
DescribeTaskDefinitionCommand,
DescribeTasksCommand,
ListClustersCommand,
ListServicesCommand,
ListTaskDefinitionsCommand,
ListTasksCommand,
RegisterTaskDefinitionCommand,
type Cluster,
type ECSClient,
type Service,
type TaskDefinition,
} from '@aws-sdk/client-ecs'
import {DescribeLoadBalancersCommand, DescribeTargetGroupsCommand, DescribeTargetHealthCommand, type ElasticLoadBalancingV2Client} from '@aws-sdk/client-elastic-load-balancing-v2'
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import {awsEcsSchema} from '../cloud-spi/ecsSchema'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
const CLUSTER_PREFIX = 'cluster:'
const TASK_DEFINITION_PREFIX = 'task-definition:'
export class AwsEcsAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'ecs' as const
@@ -13,33 +35,101 @@ export class AwsEcsAdapter implements CloudServiceAdapter {
schema(): ServiceSchema { return awsEcsSchema() }
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const resources: CloudResource[] = []
let clusterToken: string | undefined
do {
const clusters = await this.client.send(new ListClustersCommand(clusterToken ? {nextToken: clusterToken} : {}))
for (const cluster of clusters.clusterArns ?? []) {
const services = await this.services(cluster)
resources.push(...await Promise.all(services.map((service) => this.toResource(cluster, service))))
}
clusterToken = clusters.nextToken
} while (clusterToken)
const [clusterArns, taskDefinitions] = await Promise.all([this.clusterArns(), this.taskDefinitions()])
const [clusters, services] = await Promise.all([
this.describeClusters(clusterArns),
Promise.all(clusterArns.map(async (cluster) => Promise.all((await this.services(cluster)).map((service) => this.toServiceResource(cluster, service))))),
])
const resources = [
...clusters.map(toClusterResource),
...taskDefinitions.map(toTaskDefinitionResource),
...services.flat(),
]
const search = query.search?.trim().toLowerCase()
return resources.filter((resource) => !search || resource.name.toLowerCase().includes(search) || String(resource.metadata.cluster).toLowerCase().includes(search))
return resources.filter((resource) => !search
|| resource.name.toLowerCase().includes(search)
|| resource.type.toLowerCase().includes(search)
|| String(resource.metadata.cluster ?? '').toLowerCase().includes(search))
}
async get(id: string): Promise<CloudResource | null> {
const [cluster, serviceName] = splitId(id)
if (id.startsWith(CLUSTER_PREFIX)) {
const cluster = id.slice(CLUSTER_PREFIX.length)
const response = await this.client.send(new DescribeClustersCommand({clusters: [cluster]}))
return response.clusters?.[0] ? toClusterResource(response.clusters[0]) : null
}
if (id.startsWith(TASK_DEFINITION_PREFIX)) {
const taskDefinition = id.slice(TASK_DEFINITION_PREFIX.length)
const response = await this.client.send(new DescribeTaskDefinitionCommand({taskDefinition}))
return response.taskDefinition && isFargateTaskDefinition(response.taskDefinition)
? toTaskDefinitionResource(response.taskDefinition)
: null
}
const [cluster, serviceName] = splitServiceId(id)
if (!cluster || !serviceName) return null
const response = await this.client.send(new DescribeServicesCommand({cluster, services: [serviceName]}))
const service = response.services?.[0]
return service ? this.toResource(cluster, service) : null
return service ? this.toServiceResource(cluster, service) : null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const resourceKind = oneOf(input.values.resourceKind, ['service', 'cluster', 'taskDefinition'], 'resourceKind') ?? 'service'
if (resourceKind === 'cluster') return this.createCluster(input)
if (resourceKind === 'taskDefinition') return this.registerTaskDefinition(input)
return this.createService(input)
}
async delete(id: string): Promise<void> {
if (id.startsWith(CLUSTER_PREFIX)) {
await this.client.send(new DeleteClusterCommand({cluster: id.slice(CLUSTER_PREFIX.length)}))
return
}
if (id.startsWith(TASK_DEFINITION_PREFIX)) {
await this.client.send(new DeregisterTaskDefinitionCommand({taskDefinition: id.slice(TASK_DEFINITION_PREFIX.length)}))
return
}
const [cluster, serviceName] = splitServiceId(id)
if (!cluster || !serviceName) throw new ValidationError('ECS service id must be cluster|service')
await this.client.send(new DeleteServiceCommand({cluster, service: serviceName, force: true}))
}
private async createCluster(input: CreateResourceInput): Promise<CloudResource> {
const clusterName = required(input.values.clusterName, 'clusterName')
const response = await this.client.send(new CreateClusterCommand({clusterName}))
if (!response.cluster) throw new RuntimeError(`ECS did not return the created cluster ${clusterName}`)
return toClusterResource(response.cluster)
}
private async registerTaskDefinition(input: CreateResourceInput): Promise<CloudResource> {
const family = required(input.values.family, 'family')
const image = required(input.values.image, 'image')
const containerName = required(input.values.containerName, 'containerName')
const containerPort = port(input.values.containerPort)
const cpu = required(input.values.cpu, 'cpu')
const memory = required(input.values.memory, 'memory')
const response = await this.client.send(new RegisterTaskDefinitionCommand({
family,
cpu,
memory,
networkMode: 'awsvpc',
requiresCompatibilities: ['FARGATE'],
containerDefinitions: [{
name: containerName,
image,
essential: true,
portMappings: [{containerPort, protocol: 'tcp'}],
}],
}))
if (!response.taskDefinition) throw new RuntimeError(`ECS did not return the registered task definition ${family}`)
return toTaskDefinitionResource(response.taskDefinition)
}
private async createService(input: CreateResourceInput): Promise<CloudResource> {
const cluster = required(input.values.cluster, 'cluster')
const serviceName = required(input.values.serviceName, 'serviceName')
const taskDefinition = required(input.values.taskDefinition, 'taskDefinition')
const launchType = oneOf(input.values.launchType, ['EC2', 'FARGATE'], 'launchType') as 'EC2' | 'FARGATE' | undefined ?? 'FARGATE'
const deploymentController = oneOf(input.values.deploymentController, ['ECS', 'CODE_DEPLOY'], 'deploymentController') as 'ECS' | 'CODE_DEPLOY' | undefined ?? 'ECS'
const desiredCount = count(input.values.desiredCount)
const subnets = ids(input.values.subnets, 'subnets')
if (launchType === 'FARGATE' && subnets.length === 0) throw new ValidationError('subnets is required for Fargate awsvpc networking')
@@ -55,34 +145,64 @@ export class AwsEcsAdapter implements CloudServiceAdapter {
taskDefinition,
desiredCount,
launchType,
deploymentController: {type: deploymentController},
...(subnets.length ? {networkConfiguration: {awsvpcConfiguration: {subnets, ...(securityGroups.length ? {securityGroups} : {}), assignPublicIp: input.values.assignPublicIp === true || input.values.assignPublicIp === 'ENABLED' ? 'ENABLED' : 'DISABLED'}}} : {}),
...(targetGroupArn ? {loadBalancers: [{targetGroupArn, containerName, containerPort}]} : {}),
}))
if (!response.service) throw new RuntimeError(`ECS did not return the created service ${serviceName}`)
return this.toResource(cluster, response.service)
return this.toServiceResource(cluster, response.service)
}
async delete(id: string): Promise<void> {
const [cluster, serviceName] = splitId(id)
if (!cluster || !serviceName) throw new ValidationError('ECS service id must be cluster|service')
await this.client.send(new DeleteServiceCommand({cluster, service: serviceName, force: true}))
}
private async services(cluster: string): Promise<Service[]> {
const result: Service[] = []
private async clusterArns(): Promise<string[]> {
const result: string[] = []
let nextToken: string | undefined
do {
const listed = await this.client.send(new ListServicesCommand(nextToken ? {cluster, nextToken} : {cluster}))
if (listed.serviceArns?.length) {
const described = await this.client.send(new DescribeServicesCommand({cluster, services: listed.serviceArns}))
result.push(...(described.services ?? []))
}
nextToken = listed.nextToken
const response = await this.client.send(new ListClustersCommand(nextToken ? {nextToken} : {}))
result.push(...(response.clusterArns ?? []))
nextToken = response.nextToken
} while (nextToken)
return result
}
private async toResource(cluster: string, service: Service): Promise<CloudResource> {
private async describeClusters(clusterArns: string[]): Promise<Cluster[]> {
const results = await Promise.all(chunks(clusterArns, 100).map(async (clusters) => {
const response = await this.client.send(new DescribeClustersCommand({clusters, include: ['STATISTICS']}))
return response.clusters ?? []
}))
return results.flat()
}
private async taskDefinitions(): Promise<TaskDefinition[]> {
const arns: string[] = []
let nextToken: string | undefined
do {
const response = await this.client.send(new ListTaskDefinitionsCommand(nextToken ? {nextToken, status: 'ACTIVE'} : {status: 'ACTIVE'}))
arns.push(...(response.taskDefinitionArns ?? []))
nextToken = response.nextToken
} while (nextToken)
const definitions = await Promise.all(arns.map(async (taskDefinition) => {
const response = await this.client.send(new DescribeTaskDefinitionCommand({taskDefinition}))
return response.taskDefinition
}))
return definitions.filter((definition): definition is TaskDefinition => definition !== undefined && isFargateTaskDefinition(definition))
}
private async services(cluster: string): Promise<Service[]> {
const arns: string[] = []
let nextToken: string | undefined
do {
const listed = await this.client.send(new ListServicesCommand(nextToken ? {cluster, nextToken} : {cluster}))
arns.push(...(listed.serviceArns ?? []))
nextToken = listed.nextToken
} while (nextToken)
const described = await Promise.all(chunks(arns, 10).map(async (services) => {
const response = await this.client.send(new DescribeServicesCommand({cluster, services}))
return response.services ?? []
}))
return described.flat()
}
private async toServiceResource(cluster: string, service: Service): Promise<CloudResource> {
const name = service.serviceName ?? service.serviceArn ?? ''
const inactive = service.status === 'INACTIVE'
const [tasks, loadBalancers] = inactive ? [[], []] : await Promise.all([this.tasks(cluster, name), this.loadBalancerHealth(service)])
@@ -91,6 +211,7 @@ export class AwsEcsAdapter implements CloudServiceAdapter {
createdAt: service.createdAt?.toISOString() ?? null, status: service.status ?? null,
metadata: {
cluster, serviceArn: service.serviceArn, launchType: service.launchType ?? 'EC2', taskDefinition: service.taskDefinition,
deploymentController: service.deploymentController?.type ?? 'ECS',
desiredCount: inactive ? 0 : service.desiredCount ?? 0, runningCount: inactive ? 0 : service.runningCount ?? 0, pendingCount: inactive ? 0 : service.pendingCount ?? 0,
inactive,
networkConfiguration: service.networkConfiguration?.awsvpcConfiguration ? {subnets: service.networkConfiguration.awsvpcConfiguration.subnets ?? [], securityGroups: service.networkConfiguration.awsvpcConfiguration.securityGroups ?? [], assignPublicIp: service.networkConfiguration.awsvpcConfiguration.assignPublicIp ?? 'DISABLED'} : null,
@@ -122,11 +243,67 @@ export class AwsEcsAdapter implements CloudServiceAdapter {
}
}
function toClusterResource(cluster: Cluster): CloudResource {
const arn = cluster.clusterArn ?? cluster.clusterName ?? ''
return {
id: `${CLUSTER_PREFIX}${arn}`,
name: cluster.clusterName ?? arn,
cloud: 'aws',
service: 'ecs',
type: 'ecs-cluster',
region: regionFromArn(cluster.clusterArn),
createdAt: null,
status: cluster.status ?? null,
metadata: {
clusterArn: cluster.clusterArn,
registeredContainerInstancesCount: cluster.registeredContainerInstancesCount ?? 0,
runningTasksCount: cluster.runningTasksCount ?? 0,
pendingTasksCount: cluster.pendingTasksCount ?? 0,
activeServicesCount: cluster.activeServicesCount ?? 0,
statistics: cluster.statistics ?? [],
},
}
}
function toTaskDefinitionResource(taskDefinition: TaskDefinition): CloudResource {
const arn = taskDefinition.taskDefinitionArn ?? `${taskDefinition.family ?? ''}:${taskDefinition.revision ?? ''}`
const container = taskDefinition.containerDefinitions?.[0]
return {
id: `${TASK_DEFINITION_PREFIX}${arn}`,
name: `${taskDefinition.family ?? arn}:${taskDefinition.revision ?? ''}`,
cloud: 'aws',
service: 'ecs',
type: 'ecs-task-definition',
region: regionFromArn(taskDefinition.taskDefinitionArn),
createdAt: taskDefinition.registeredAt?.toISOString() ?? null,
status: taskDefinition.status ?? null,
metadata: {
taskDefinitionArn: taskDefinition.taskDefinitionArn,
family: taskDefinition.family,
revision: taskDefinition.revision,
cpu: taskDefinition.cpu,
memory: taskDefinition.memory,
networkMode: taskDefinition.networkMode,
requiresCompatibilities: taskDefinition.requiresCompatibilities ?? [],
containerName: container?.name,
image: container?.image,
containerPort: container?.portMappings?.[0]?.containerPort,
containers: (taskDefinition.containerDefinitions ?? []).map((definition) => ({name: definition.name, image: definition.image, cpu: definition.cpu, memory: definition.memory, ports: (definition.portMappings ?? []).map((mapping) => mapping.containerPort)})),
},
}
}
function isFargateTaskDefinition(taskDefinition: TaskDefinition): boolean {
return taskDefinition.networkMode === 'awsvpc' && taskDefinition.requiresCompatibilities?.includes('FARGATE') === true
}
function required(value: unknown, field: string): string { const result = optional(value, field); if (!result) throw new ValidationError(`${field} is required`); return result }
function optional(value: unknown, field: string): string | undefined { if (value === undefined || value === null || value === '') return undefined; if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`); return value.trim() || undefined }
function ids(value: unknown, field: string): string[] { const raw = optional(value, field); return raw ? raw.split(',').map((id) => id.trim()).filter(Boolean) : [] }
function count(value: unknown): number { if (value === undefined || value === '') return 1; const result = Number(value); if (!Number.isInteger(result) || result < 0) throw new ValidationError('desiredCount must be a non-negative integer'); return result }
function port(value: unknown): number { const result = optionalPort(value); if (!result) throw new ValidationError('containerPort is required'); return result }
function optionalPort(value: unknown): number | undefined { if (value === undefined || value === '') return undefined; const result = Number(value); if (!Number.isInteger(result) || result < 1 || result > 65535) throw new ValidationError('containerPort must be an integer from 1 to 65535'); return result }
function oneOf(value: unknown, choices: string[], field: string): string | undefined { const result = optional(value, field); if (result && !choices.includes(result)) throw new ValidationError(`${field} must be one of ${choices.join(', ')}`); return result }
function splitId(id: string): [string, string] { const separator = id.indexOf('|'); return separator > 0 && separator < id.length - 1 ? [id.slice(0, separator), id.slice(separator + 1)] : ['', ''] }
function splitServiceId(id: string): [string, string] { const separator = id.indexOf('|'); return separator > 0 && separator < id.length - 1 ? [id.slice(0, separator), id.slice(separator + 1)] : ['', ''] }
function regionFromArn(arn?: string): string | null { return arn?.split(':')[3] || null }
function chunks<T>(values: T[], size: number): T[][] { const result: T[][] = []; for (let index = 0; index < values.length; index += size) result.push(values.slice(index, index + size)); return result }
@@ -0,0 +1,94 @@
import {describe, expect, test} from 'bun:test'
import {
CreateStreamCommand,
DeleteStreamCommand,
DescribeStreamSummaryCommand,
ListStreamsCommand,
GetRecordsCommand,
GetShardIteratorCommand,
ListShardsCommand,
PutRecordCommand,
type KinesisClient,
} from '@aws-sdk/client-kinesis'
import {ValidationError} from '../cloud-spi/errors'
import {AwsKinesisAdapter} from './AwsKinesisAdapter'
function stubKinesis() {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
if (command instanceof ListStreamsCommand) {
return {StreamNames: ['orders']}
}
if (command instanceof DescribeStreamSummaryCommand) {
return {StreamDescriptionSummary: {
StreamName: command.input.StreamName,
StreamARN: `arn:aws:kinesis:us-east-1:000000000000:stream/${command.input.StreamName}`,
StreamStatus: 'ACTIVE',
OpenShardCount: 1,
StreamModeDetails: {StreamMode: 'PROVISIONED'},
StreamCreationTimestamp: new Date('2026-10-04T00:00:00Z'),
}}
}
if (command instanceof ListShardsCommand) return {Shards: [{ShardId: 'shard-000'}]}
if (command instanceof GetShardIteratorCommand) return {ShardIterator: 'iterator'}
if (command instanceof GetRecordsCommand) return {Records: [{SequenceNumber: '1', PartitionKey: 'orders', Data: new TextEncoder().encode('{"id":1}')}]}
return {}
},
} as unknown as KinesisClient
return {client, sent}
}
describe('AwsKinesisAdapter', () => {
test('registers as the AWS Kinesis adapter', () => {
const adapter = new AwsKinesisAdapter(stubKinesis().client)
expect(adapter.cloud).toBe('aws')
expect(adapter.service).toBe('kinesis')
expect(adapter.schema().displayName).toBe('Amazon Kinesis Data Streams')
})
test('lists stream summaries as Cloud Explorer resources', async () => {
const [stream] = await new AwsKinesisAdapter(stubKinesis().client).list()
expect(stream).toMatchObject({id: 'orders', service: 'kinesis', status: 'ACTIVE'})
expect(stream?.metadata.openShardCount).toBe(1)
})
test('uses the valid AWS request shape for each capacity mode', async () => {
const provisioned = stubKinesis()
await new AwsKinesisAdapter(provisioned.client).create({values: {streamName: 'orders', streamMode: 'PROVISIONED', shardCount: '2'}})
expect(provisioned.sent.find((command) => command instanceof CreateStreamCommand))
.toMatchObject({input: {StreamName: 'orders', ShardCount: 2}})
const onDemand = stubKinesis()
await new AwsKinesisAdapter(onDemand.client).create({values: {streamName: 'events', streamMode: 'ON_DEMAND'}})
expect(onDemand.sent.find((command) => command instanceof CreateStreamCommand))
.toMatchObject({input: {StreamName: 'events', StreamModeDetails: {StreamMode: 'ON_DEMAND'}}})
})
test('rejects an invalid provisioned shard count before calling AWS', async () => {
const adapter = new AwsKinesisAdapter(stubKinesis().client)
await expect(adapter.create({values: {streamName: 'orders', streamMode: 'PROVISIONED', shardCount: '0'}}))
.rejects.toBeInstanceOf(ValidationError)
})
test('deletes by stream name', async () => {
const stub = stubKinesis()
await new AwsKinesisAdapter(stub.client).delete('orders')
expect(stub.sent.find((command) => command instanceof DeleteStreamCommand))
.toMatchObject({input: {StreamName: 'orders'}})
})
test('uses Kinesis record commands to produce and inspect stream records', async () => {
const stub = stubKinesis()
const adapter = new AwsKinesisAdapter(stub.client)
await expect(adapter.putKinesisRecord('orders', {partitionKey: 'orders', data: '{"id":1}'})).rejects.toBeInstanceOf(ValidationError)
// The SDK returns this pair from PutRecord; use a dedicated client response for that contract.
const client = {send: async (command: object) => command instanceof PutRecordCommand ? {SequenceNumber: '2', ShardId: 'shard-000'} : stub.client.send(command as never)} as unknown as KinesisClient
const recordsAdapter = new AwsKinesisAdapter(client)
await expect(recordsAdapter.putKinesisRecord('orders', {partitionKey: 'orders', data: '{"id":2}'})).resolves.toEqual({sequenceNumber: '2', shardId: 'shard-000'})
await expect(recordsAdapter.listKinesisRecords('orders')).resolves.toEqual([{sequenceNumber: '1', partitionKey: 'orders', data: '{"id":1}', approximateArrivalTimestamp: null}])
expect(stub.sent.some((command) => command instanceof GetShardIteratorCommand)).toBe(true)
expect(stub.sent.some((command) => command instanceof GetRecordsCommand)).toBe(true)
})
})
@@ -0,0 +1,151 @@
import {
CreateStreamCommand,
DeleteStreamCommand,
DescribeStreamSummaryCommand,
ListStreamsCommand,
GetRecordsCommand,
GetShardIteratorCommand,
ListShardsCommand,
PutRecordCommand,
type KinesisClient,
} from '@aws-sdk/client-kinesis'
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
import {awsKinesisSchema} from '../cloud-spi/kinesisSchema'
import {kinesis as defaultKinesis} from '../aws'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, KinesisPutRecordInput, KinesisRecord, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
export class AwsKinesisAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'kinesis' as const
constructor(private readonly kinesis: KinesisClient = defaultKinesis) {}
schema(): ServiceSchema {
return awsKinesisSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const response = await this.kinesis.send(new ListStreamsCommand({}))
const streams = await Promise.all((response.StreamNames ?? []).map((name) => this.get(name)))
const search = query.search?.trim().toLowerCase()
return streams.filter((stream): stream is CloudResource => stream !== null)
.filter((stream) => !search || stream.name.toLowerCase().includes(search))
}
async get(id: string): Promise<CloudResource | null> {
try {
const response = await this.kinesis.send(new DescribeStreamSummaryCommand({StreamName: id}))
const stream = response.StreamDescriptionSummary
if (!stream) {
return null
}
return {
id: stream.StreamName ?? id,
name: stream.StreamName ?? id,
cloud: 'aws',
service: 'kinesis',
type: 'stream',
region: null,
createdAt: stream.StreamCreationTimestamp?.toISOString() ?? null,
status: stream.StreamStatus ?? null,
metadata: {
arn: stream.StreamARN,
streamMode: stream.StreamModeDetails?.StreamMode,
openShardCount: stream.OpenShardCount,
retentionPeriodHours: stream.RetentionPeriodHours,
encryptionType: stream.EncryptionType,
keyId: stream.KeyId,
},
}
} catch (error) {
if (isMissingStream(error)) {
return null
}
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const streamName = text(input.values.streamName ?? input.values.name)
const streamMode = text(input.values.streamMode) || 'PROVISIONED'
if (!streamName) {
throw new ValidationError('streamName is required')
}
if (streamMode !== 'PROVISIONED' && streamMode !== 'ON_DEMAND') {
throw new ValidationError('streamMode must be PROVISIONED or ON_DEMAND')
}
const request = streamMode === 'ON_DEMAND'
? {StreamName: streamName, StreamModeDetails: {StreamMode: 'ON_DEMAND' as const}}
: {StreamName: streamName, ShardCount: positiveInteger(input.values.shardCount, 'shardCount')}
await this.kinesis.send(new CreateStreamCommand(request))
const stream = await this.get(streamName)
if (!stream) {
throw new ValidationError(`Stream ${streamName} was not found after creation`)
}
return stream
}
async delete(id: string): Promise<void> {
const stream = await this.get(id)
if (!stream) {
throw new NotFoundError(`Kinesis stream ${id} does not exist`)
}
await this.kinesis.send(new DeleteStreamCommand({StreamName: id}))
}
async health(): Promise<void> {
await this.kinesis.send(new ListStreamsCommand({Limit: 1}))
}
async putKinesisRecord(streamName: string, input: KinesisPutRecordInput): Promise<{sequenceNumber: string; shardId: string}> {
const partitionKey = text(input.partitionKey)
if (!partitionKey) throw new ValidationError('partitionKey is required')
const response = await this.kinesis.send(new PutRecordCommand({
StreamName: streamName,
PartitionKey: partitionKey,
Data: new TextEncoder().encode(input.data),
}))
if (!response.SequenceNumber || !response.ShardId) throw new ValidationError('Kinesis did not return a sequence number and shard id')
return {sequenceNumber: response.SequenceNumber, shardId: response.ShardId}
}
async listKinesisRecords(streamName: string): Promise<KinesisRecord[]> {
const shards = await this.kinesis.send(new ListShardsCommand({StreamName: streamName}))
const records: KinesisRecord[] = []
for (const shard of shards.Shards ?? []) {
if (!shard.ShardId) continue
const iterator = await this.kinesis.send(new GetShardIteratorCommand({
StreamName: streamName, ShardId: shard.ShardId, ShardIteratorType: 'TRIM_HORIZON',
}))
if (!iterator.ShardIterator) continue
const response = await this.kinesis.send(new GetRecordsCommand({ShardIterator: iterator.ShardIterator, Limit: 100}))
for (const record of response.Records ?? []) {
if (!record.SequenceNumber) continue
records.push({
sequenceNumber: record.SequenceNumber,
partitionKey: record.PartitionKey ?? null,
data: new TextDecoder().decode(record.Data),
approximateArrivalTimestamp: record.ApproximateArrivalTimestamp?.toISOString() ?? null,
})
}
}
return records
}
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function positiveInteger(value: unknown, field: string): number {
const parsed = Number(text(value))
if (!Number.isInteger(parsed) || parsed < 1) {
throw new ValidationError(`${field} must be a positive whole number`)
}
return parsed
}
function isMissingStream(error: unknown): boolean {
return typeof error === 'object' && error !== null
&& (error as {name?: string}).name === 'ResourceNotFoundException'
}
@@ -0,0 +1,69 @@
import {describe, expect, test} from 'bun:test'
import {AwsNetworkingAdapter} from './AwsNetworkingAdapter'
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
import type {Ec2SecurityGroup, Ec2Vpc, IpPermissionInput} from '../services/ec2'
const vpc: Ec2Vpc = {
vpcId: 'vpc-1',
cidrBlock: '10.0.0.0/16',
state: 'available',
isDefault: true,
tags: [],
}
const group: Ec2SecurityGroup = {
groupId: 'sg-web',
groupName: 'web',
description: 'Web access',
vpcId: 'vpc-1',
inboundRules: [{protocol: 'tcp', fromPort: 80, toPort: 80, ipRanges: ['0.0.0.0/0'], ipv6Ranges: []}],
outboundRules: [],
tags: [],
}
function fakeService() {
const authorized: Array<{groupId: string; permission: IpPermissionInput}> = []
const revoked: Array<{groupId: string; permission: IpPermissionInput}> = []
return {
authorized,
revoked,
listVpcs: async (): Promise<Ec2Vpc[]> => [vpc],
listSecurityGroups: async (): Promise<Ec2SecurityGroup[]> => [group],
authorizeSecurityGroupIngress: async (groupId: string, permission: IpPermissionInput) => { authorized.push({groupId, permission}) },
revokeSecurityGroupIngress: async (groupId: string, permission: IpPermissionInput) => { revoked.push({groupId, permission}) },
}
}
describe('AwsNetworkingAdapter security-group ingress items', () => {
test('lists each IPv4 ingress range as an inspectable child item', async () => {
const service = fakeService()
const page = await new AwsNetworkingAdapter(service).items.listItems('sg-web')
expect(page.nextCursor).toBeNull()
expect(page.items).toHaveLength(1)
expect(page.items[0]).toMatchObject({
collectionId: null,
body: {protocol: 'tcp', fromPort: 80, toPort: 80, cidr: '0.0.0.0/0'},
})
})
test('adds and deletes ingress through the EC2 service using the opaque item id', async () => {
const service = fakeService()
const items = new AwsNetworkingAdapter(service).items
const permission = {protocol: 'tcp', fromPort: 443, toPort: 443, cidr: '10.0.0.0/8'}
const created = await items.putItem!('sg-web', permission)
await items.deleteItem!('sg-web', created.id)
expect(service.authorized).toEqual([{groupId: 'sg-web', permission}])
expect(service.revoked).toEqual([{groupId: 'sg-web', permission}])
})
test('rejects malformed rules and unknown security groups', async () => {
const items = new AwsNetworkingAdapter(fakeService()).items
await expect(items.putItem!('sg-web', {protocol: 'tcp', fromPort: 9000, toPort: 80, cidr: '0.0.0.0/0'})).rejects.toBeInstanceOf(ValidationError)
await expect(items.listItems('sg-missing')).rejects.toBeInstanceOf(NotFoundError)
await expect(items.deleteItem!('sg-web', 'not-a-rule')).rejects.toBeInstanceOf(ValidationError)
})
})
@@ -1,10 +1,14 @@
import {NotSupportedError} from '../cloud-spi/errors'
import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections'
import {NotFoundError, NotSupportedError, ValidationError} from '../cloud-spi/errors'
import {awsNetworkingSchema} from '../cloud-spi/networkingSchema'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
import {ec2Service, type Ec2Tag, type Ec2Vpc} from '../services/ec2'
import {ec2Service, type Ec2IpPermission, type Ec2SecurityGroup, type Ec2Tag, type Ec2Vpc, type IpPermissionInput} from '../services/ec2'
type Ec2NetworkingServiceShape = {
listVpcs(): Promise<Ec2Vpc[]>
listSecurityGroups(vpcId?: string): Promise<Ec2SecurityGroup[]>
authorizeSecurityGroupIngress(groupId: string, permission: IpPermissionInput): Promise<void>
revokeSecurityGroupIngress(groupId: string, permission: IpPermissionInput): Promise<void>
}
/**
@@ -18,8 +22,11 @@ type Ec2NetworkingServiceShape = {
export class AwsNetworkingAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'networking' as const
readonly items: ItemStoreAdapter
constructor(private readonly service_: Ec2NetworkingServiceShape = ec2Service) {}
constructor(private readonly service_: Ec2NetworkingServiceShape = ec2Service) {
this.items = new SecurityGroupIngressStore(service_)
}
schema(): ServiceSchema {
return awsNetworkingSchema()
@@ -48,6 +55,87 @@ export class AwsNetworkingAdapter implements CloudServiceAdapter {
}
}
class SecurityGroupIngressStore implements ItemStoreAdapter {
constructor(private readonly service: Ec2NetworkingServiceShape) {}
async listItems(groupId: string, _page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
const groups = await this.service.listSecurityGroups()
const group = groups.find((candidate) => candidate.groupId === groupId)
if (!group) throw new NotFoundError(`Security group ${groupId} does not exist`)
return {
items: group.inboundRules.flatMap(permissionToItems),
nextCursor: null,
}
}
async putItem(groupId: string, body: Record<string, unknown>): Promise<ChildItem> {
const permission = permissionFromBody(body)
await this.service.authorizeSecurityGroupIngress(groupId, permission)
return permissionToItem(permission)
}
async deleteItem(groupId: string, itemId: string): Promise<void> {
await this.service.revokeSecurityGroupIngress(groupId, decodePermission(itemId))
}
}
function permissionToItems(permission: Ec2IpPermission): ChildItem[] {
return permission.ipRanges.map((cidr) => permissionToItem({
protocol: permission.protocol,
fromPort: permission.fromPort ?? 0,
toPort: permission.toPort ?? 0,
cidr,
}))
}
function permissionToItem(permission: IpPermissionInput): ChildItem {
return {
id: encodePermission(permission),
collectionId: null,
timestamp: null,
body: permission,
metadata: {},
}
}
function permissionFromBody(body: Record<string, unknown>): IpPermissionInput {
const protocol = requiredString(body.protocol, 'protocol')
const fromPort = requiredPort(body.fromPort, 'fromPort')
const toPort = requiredPort(body.toPort, 'toPort')
const cidr = requiredString(body.cidr, 'cidr')
if (fromPort > toPort) throw new ValidationError('fromPort must not exceed toPort')
return {protocol, fromPort, toPort, cidr}
}
function requiredString(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim()) throw new ValidationError(`${field} is required`)
return value.trim()
}
function requiredPort(value: unknown, field: string): number {
const port = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : Number.NaN
if (!Number.isInteger(port) || port < -1 || port > 65535) {
throw new ValidationError(`${field} must be an integer from -1 to 65535`)
}
return port
}
function encodePermission(permission: IpPermissionInput): string {
return Buffer.from(JSON.stringify(permission)).toString('base64url')
}
function decodePermission(itemId: string): IpPermissionInput {
try {
const decoded: unknown = JSON.parse(Buffer.from(itemId, 'base64url').toString('utf8'))
if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) throw new Error('invalid shape')
return permissionFromBody(decoded as Record<string, unknown>)
} catch (error) {
if (error instanceof ValidationError) throw error
throw new ValidationError('Invalid security-group ingress rule id', {cause: error})
}
}
function vpcToResource(vpc: Ec2Vpc): CloudResource {
return {
id: vpc.vpcId,
@@ -4,7 +4,8 @@ import {
CreateFunctionCommand,
CreateEventSourceMappingCommand,
DeleteFunctionCommand,
DeleteEventSourceMappingCommand,
DeleteEventSourceMappingCommand,
UpdateEventSourceMappingCommand,
GetFunctionCommand,
InvokeCommand,
type LambdaClient,
@@ -450,4 +451,27 @@ describe('AwsServerlessAdapter', () => {
expect((sent[0] as DeleteEventSourceMappingCommand).input).toEqual({UUID: 'mapping-1'})
})
})
describe('generic Lambda event source mappings', () => {
const streamArn = 'arn:aws:kinesis:us-east-1:000000000000:stream/orders'
test('creates, lists, updates, and deletes a Kinesis mapping through Lambda SDK commands', async () => {
const {client, sent} = stubLambda((command) => {
if (command instanceof CreateEventSourceMappingCommand || command instanceof UpdateEventSourceMappingCommand) {
return {UUID: 'mapping-1', EventSourceArn: streamArn, FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:worker', State: 'Enabled'}
}
if (command instanceof ListEventSourceMappingsCommand) return {EventSourceMappings: [{UUID: 'mapping-1', EventSourceArn: streamArn}]}
return {}
})
const adapter = new AwsServerlessAdapter(client)
await adapter.createLambdaEventSourceMapping({functionName: 'worker', eventSourceArn: streamArn, batchSize: 100, startingPosition: 'LATEST'})
await adapter.listLambdaEventSourceMappings({eventSourceArn: streamArn})
await adapter.updateLambdaEventSourceMapping('mapping-1', {enabled: false})
await adapter.deleteLambdaEventSourceMapping('mapping-1')
expect((sent[0] as CreateEventSourceMappingCommand).input).toEqual({FunctionName: 'worker', EventSourceArn: streamArn, BatchSize: 100, StartingPosition: 'LATEST'})
expect((sent[1] as ListEventSourceMappingsCommand).input.EventSourceArn).toBe(streamArn)
expect((sent[2] as UpdateEventSourceMappingCommand).input).toEqual({UUID: 'mapping-1', Enabled: false})
expect((sent[3] as DeleteEventSourceMappingCommand).input).toEqual({UUID: 'mapping-1'})
})
})
})
@@ -5,6 +5,7 @@ import {
AddPermissionCommand,
DeleteFunctionCommand,
DeleteEventSourceMappingCommand,
UpdateEventSourceMappingCommand,
GetFunctionCommand,
InvokeCommand,
type InvokeCommandOutput,
@@ -34,6 +35,8 @@ import type {
EventBridgeLambdaTriggerOverview,
SqsLambdaEventSourceMappingInput,
SqsLambdaEventSourceMappingOverview,
LambdaEventSourceMappingInput,
LambdaEventSourceMappingOverview,
ServerlessInvokeResult,
ServiceSchema,
UpdateResourceInput,
@@ -385,6 +388,57 @@ export class AwsServerlessAdapter implements CloudServiceAdapter {
await this.lambda.send(new DeleteEventSourceMappingCommand({UUID: uuid}))
}
async createLambdaEventSourceMapping(input: LambdaEventSourceMappingInput): Promise<LambdaEventSourceMappingOverview> {
const functionName = stringValue(input.functionName)
const eventSourceArn = stringValue(input.eventSourceArn)
if (!functionName) throw new ValidationError('functionName is required')
if (!eventSourceArn) throw new ValidationError('eventSourceArn is required')
const isKinesis = eventSourceArn.includes(':kinesis:')
if (isKinesis && !input.startingPosition) throw new ValidationError('startingPosition is required for Kinesis event sources')
const mapping = await this.lambda.send(new CreateEventSourceMappingCommand({
FunctionName: functionName,
EventSourceArn: eventSourceArn,
...(input.batchSize === undefined ? {} : {BatchSize: eventSourceBatchSize(input.batchSize, isKinesis ? 10_000 : 10)}),
...(input.startingPosition ? {StartingPosition: input.startingPosition} : {}),
}))
return lambdaEventSourceMappingOverview(mapping)
}
async listLambdaEventSourceMappings(filter: {functionName?: string; eventSourceArn?: string} = {}): Promise<LambdaEventSourceMappingOverview[]> {
const mappings: LambdaEventSourceMappingOverview[] = []
let marker: string | undefined
do {
const result = await this.lambda.send(new ListEventSourceMappingsCommand({
...(filter.functionName ? {FunctionName: filter.functionName} : {}),
...(filter.eventSourceArn ? {EventSourceArn: filter.eventSourceArn} : {}),
Marker: marker,
}))
for (const mapping of result.EventSourceMappings ?? []) {
if (mapping.UUID && mapping.EventSourceArn) mappings.push(lambdaEventSourceMappingOverview(mapping))
}
marker = result.NextMarker
} while (marker)
return mappings.sort((left, right) => left.uuid.localeCompare(right.uuid))
}
async updateLambdaEventSourceMapping(mappingId: string, input: {batchSize?: number; enabled?: boolean}): Promise<LambdaEventSourceMappingOverview> {
const uuid = stringValue(mappingId)
if (!uuid) throw new ValidationError('mappingId is required')
if (input.batchSize === undefined && input.enabled === undefined) throw new ValidationError('batchSize or enabled is required')
const mapping = await this.lambda.send(new UpdateEventSourceMappingCommand({
UUID: uuid,
...(input.batchSize === undefined ? {} : {BatchSize: eventSourceBatchSize(input.batchSize, 10_000)}),
...(input.enabled === undefined ? {} : {Enabled: input.enabled}),
}))
return lambdaEventSourceMappingOverview(mapping)
}
async deleteLambdaEventSourceMapping(mappingId: string): Promise<void> {
const uuid = stringValue(mappingId)
if (!uuid) throw new ValidationError('mappingId is required')
await this.lambda.send(new DeleteEventSourceMappingCommand({UUID: uuid}))
}
private async listEventBridgeTargets(ruleName: string) {
const targets = []
let nextToken: string | undefined
@@ -457,6 +511,22 @@ function sqsBatchSize(value: number): number {
return value
}
function eventSourceBatchSize(value: number, maximum: number): number {
if (!Number.isInteger(value) || value < 1 || value > maximum) throw new ValidationError(`batchSize must be an integer between 1 and ${maximum}`)
return value
}
function lambdaEventSourceMappingOverview(
mapping: {UUID?: string; FunctionArn?: string; EventSourceArn?: string; BatchSize?: number; StartingPosition?: string; State?: string; StateTransitionReason?: string},
): LambdaEventSourceMappingOverview {
if (!mapping.UUID || !mapping.EventSourceArn) throw new ValidationError('Lambda event source mapping did not return a UUID and event source ARN')
return {
id: mapping.UUID, uuid: mapping.UUID, functionName: mapping.FunctionArn ? lambdaFunctionName(mapping.FunctionArn) : null,
functionArn: mapping.FunctionArn ?? null, eventSourceArn: mapping.EventSourceArn, batchSize: mapping.BatchSize ?? null,
startingPosition: mapping.StartingPosition ?? null, state: mapping.State ?? null, stateTransitionReason: mapping.StateTransitionReason ?? null,
}
}
function sqsEventSourceMappingOverview(
mapping: {UUID?: string; FunctionArn?: string; EventSourceArn?: string; BatchSize?: number; State?: string; StateTransitionReason?: string},
functionName: string,
+16
View File
@@ -23,6 +23,11 @@ import { CodeBuildClient } from "@aws-sdk/client-codebuild";
import { CodePipelineClient } from "@aws-sdk/client-codepipeline";
import { CodeDeployClient } from "@aws-sdk/client-codedeploy";
import { ECSClient } from "@aws-sdk/client-ecs";
import { KafkaClient } from "@aws-sdk/client-kafka";
import { KafkaConnectClient } from "@aws-sdk/client-kafkaconnect";
import { KinesisClient } from "@aws-sdk/client-kinesis";
import { SESv2Client } from "@aws-sdk/client-sesv2";
import { CloudWatchClient } from "@aws-sdk/client-cloudwatch";
const endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1";
@@ -77,6 +82,11 @@ export type AwsClients = {
codepipeline: CodePipelineClient;
codedeploy: CodeDeployClient;
ecs: ECSClient;
kafka: KafkaClient;
kafkaConnect: KafkaConnectClient;
kinesis: KinesisClient;
sesv2: SESv2Client;
cloudwatch: CloudWatchClient;
};
export type AwsClientName = keyof AwsClients;
@@ -115,6 +125,11 @@ function buildClients(accountId: string): AwsClients {
codepipeline: new CodePipelineClient(base),
codedeploy: new CodeDeployClient(base),
ecs: new ECSClient(base),
kafka: new KafkaClient(base),
kafkaConnect: new KafkaConnectClient(base),
kinesis: new KinesisClient(base),
sesv2: new SESv2Client(base),
cloudwatch: new CloudWatchClient(base),
};
}
@@ -153,3 +168,4 @@ export const dynamodb = awsClients.dynamodb;
export const apiGateway = awsClients.apiGateway;
export const cloudformation = awsClients.cloudformation;
export const ecr = awsClients.ecr;
export const kinesis = awsClients.kinesis;
@@ -0,0 +1,19 @@
import {describe, expect, test} from 'bun:test'
import {awsCodeBuildSchema, DEFAULT_DOCKER_TO_ECR_BUILDSPEC} from './codebuildSchema'
describe('awsCodeBuildSchema', () => {
test('provides a phase-local Floci Node 20 Docker-to-ECR buildspec for CodeDeployToECS', () => {
const schema = awsCodeBuildSchema()
const environmentImage = schema.fields.find((field) => field.name === 'environmentImage')
const buildspec = schema.fields.find((field) => field.name === 'buildspec')
expect(environmentImage?.defaultValue).toBe('node:20-bookworm')
expect(buildspec?.defaultValueWhen?.values.NO_SOURCE).toBe(DEFAULT_DOCKER_TO_ECR_BUILDSPEC)
expect(DEFAULT_DOCKER_TO_ECR_BUILDSPEC.match(/describe-repositories/g)).toHaveLength(3)
expect(DEFAULT_DOCKER_TO_ECR_BUILDSPEC.match(/--endpoint-url "\$AWS_ENDPOINT_URL"/g)).toHaveLength(4)
expect(DEFAULT_DOCKER_TO_ECR_BUILDSPEC).toContain('printf \'{"ImageURI":"%s"}\'')
expect(DEFAULT_DOCKER_TO_ECR_BUILDSPEC).not.toContain('amazonaws.com')
expect(DEFAULT_DOCKER_TO_ECR_BUILDSPEC).toContain(' - taskdef.json')
expect(DEFAULT_DOCKER_TO_ECR_BUILDSPEC).toContain(' - appspec.yaml')
})
})
@@ -9,6 +9,8 @@ const projectColumns: TableColumnSchema[] = [
const projectFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
export const DEFAULT_DOCKER_TO_ECR_BUILDSPEC = 'version: 0.2\nphases:\n install:\n commands:\n - apt-get update && apt-get install -y --no-install-recommends awscli docker.io\n pre_build:\n commands:\n - REPOSITORY_URI=$(aws --endpoint-url "$AWS_ENDPOINT_URL" ecr describe-repositories --repository-names "$ECR_REPOSITORY" --query "repositories[0].repositoryUri" --output text)\n - ECR_REGISTRY=${REPOSITORY_URI%%/*}\n - aws --endpoint-url "$AWS_ENDPOINT_URL" ecr get-login-password | docker login --username AWS --password-stdin "$ECR_REGISTRY"\n build:\n commands:\n - REPOSITORY_URI=$(aws --endpoint-url "$AWS_ENDPOINT_URL" ecr describe-repositories --repository-names "$ECR_REPOSITORY" --query "repositories[0].repositoryUri" --output text)\n - IMAGE_TAG=build-$CODEBUILD_BUILD_NUMBER\n - docker build -t "$REPOSITORY_URI:$IMAGE_TAG" .\n - docker push "$REPOSITORY_URI:$IMAGE_TAG"\n post_build:\n commands:\n - REPOSITORY_URI=$(aws --endpoint-url "$AWS_ENDPOINT_URL" ecr describe-repositories --repository-names "$ECR_REPOSITORY" --query "repositories[0].repositoryUri" --output text)\n - IMAGE_TAG=build-$CODEBUILD_BUILD_NUMBER\n - printf \'{"ImageURI":"%s"}\' "$REPOSITORY_URI:$IMAGE_TAG" > imageDetail.json\nartifacts:\n files:\n - imageDetail.json\n - taskdef.json\n - appspec.yaml'
/** The console exposes only source and artifact modes the local runtime can execute. */
export function awsCodeBuildSchema(): ServiceSchema {
return {
@@ -33,8 +35,8 @@ export function awsCodeBuildSchema(): ServiceSchema {
},
{
name: 'buildspec', label: 'Buildspec', type: 'textarea', required: false, group: 'Source', span: true,
defaultValueWhen: {field: 'sourceType', values: {NO_SOURCE: 'version: 0.2\nphases:\n pre_build:\n commands:\n - AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)\n - REPOSITORY_URI=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$ECR_REPOSITORY\n - aws ecr get-login-password | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com\n build:\n commands:\n - IMAGE_TAG=${CODEBUILD_RESOLVED_SOURCE_VERSION:0:7}\n - docker build -t $REPOSITORY_URI:$IMAGE_TAG .\n - docker push $REPOSITORY_URI:$IMAGE_TAG\n post_build:\n commands:\n - printf \'[{"name":"IMAGE1_NAME","imageUri":"%s"}]\' "$REPOSITORY_URI:$IMAGE_TAG" > imageDetail.json\nartifacts:\n files:\n - imageDetail.json\n - taskdef.json\n - appspec.yaml', S3: ''}},
description: 'For no source, this is a Docker-to-ECR buildspec. For S3, leave blank to use buildspec.yml from the source, or provide an override.',
defaultValueWhen: {field: 'sourceType', values: {NO_SOURCE: DEFAULT_DOCKER_TO_ECR_BUILDSPEC, S3: ''}},
description: 'For no source, this is a Floci Node 20 Docker-to-ECR buildspec. For S3, leave blank to use buildspec.yml from the source, or provide an override. Include taskdef.json and appspec.yaml in the source for CodeDeployToECS.',
},
{
name: 'artifactsType', label: 'Artifact type', type: 'select', required: true, group: 'Artifacts', defaultValue: 'NO_ARTIFACTS',
@@ -50,7 +52,7 @@ export function awsCodeBuildSchema(): ServiceSchema {
},
{
name: 'environmentImage', label: 'Runtime image', type: 'select', required: true, group: 'Environment',
defaultValue: 'aws/codebuild/standard:7.0',
defaultValue: 'node:20-bookworm',
options: [
{label: 'CodeBuild Standard 7.0', value: 'aws/codebuild/standard:7.0'},
{label: 'Node.js 20 (Debian)', value: 'node:20-bookworm'},
@@ -68,7 +70,7 @@ export function awsCodeBuildSchema(): ServiceSchema {
{
name: 'dockerPrivilegedMode', label: 'Docker daemon', type: 'select', required: true, group: 'Docker and ECR', defaultValue: 'enabled',
options: [{label: 'Enabled (required for Docker builds)', value: 'enabled'}],
description: 'CodeBuild runs Docker-in-Docker in privileged mode. This console flow targets ECS EC2 only.',
description: 'CodeBuild runs Docker-in-Docker in privileged mode for ECS EC2 and Fargate deployment artifacts.',
},
],
actions: ['list', 'create', 'delete', 'inspect'],
@@ -3,6 +3,7 @@ import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types'
const columns: TableColumnSchema[] = [
{name: 'name', label: 'Deployment group'},
{name: 'applicationName', label: 'Application', path: 'metadata.applicationName'},
{name: 'computePlatform', label: 'Platform', path: 'metadata.computePlatform', format: 'badge'},
{name: 'deploymentConfigName', label: 'Configuration', path: 'metadata.deploymentConfigName', format: 'badge'},
{name: 'updatedAt', label: 'Updated', path: 'metadata.updatedAt', format: 'datetime'},
]
@@ -0,0 +1,12 @@
import {describe, expect, test} from 'bun:test'
import {awsCodePipelineSchema} from './codepipelineSchema'
describe('awsCodePipelineSchema', () => {
test('documents CodeDeployToECS artifacts and both supported ECS launch types', () => {
const field = awsCodePipelineSchema().fields.find((item) => item.name === 'imageContainerName')
expect(field?.description).toContain('{"ImageURI":"..."}')
expect(field?.description).toContain('taskdef.json and appspec.yaml')
expect(field?.description).toContain('ECS EC2 or Fargate service')
})
})
@@ -22,7 +22,7 @@ export function awsCodePipelineSchema(): ServiceSchema {
{name: 'codeDeployDeploymentGroup', label: 'CodeDeploy deployment group', type: 'text', required: true, group: 'ECS deploy'},
{name: 'taskDefinitionPath', label: 'Task definition path', type: 'text', required: true, group: 'ECS deploy', defaultValue: 'taskdef.json'},
{name: 'appSpecPath', label: 'AppSpec path', type: 'text', required: true, group: 'ECS deploy', defaultValue: 'appspec.yaml'},
{name: 'imageContainerName', label: 'Task definition image placeholder', type: 'text', required: true, group: 'ECS deploy', defaultValue: 'IMAGE1_NAME', description: 'The placeholder in taskdef.json, for example <IMAGE1_NAME>. CodeBuild supplies the ECR image from BuildOutput. The CodeDeploy deployment group must target an ECS EC2 service.'},
{name: 'imageContainerName', label: 'Task definition image placeholder', type: 'text', required: true, group: 'ECS deploy', defaultValue: 'IMAGE1_NAME', description: 'The placeholder in taskdef.json, for example <IMAGE1_NAME>. BuildOutput must contain imageDetail.json as {"ImageURI":"..."}, plus taskdef.json and appspec.yaml. The deployment group may target an ECS EC2 or Fargate service.'},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {resourceActions: [
+29 -27
View File
@@ -1,7 +1,8 @@
import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types'
const columns: TableColumnSchema[] = [
{name: 'name', label: 'Service'},
{name: 'name', label: 'Resource'},
{name: 'type', label: 'Type', format: 'badge'},
{name: 'status', label: 'Status', format: 'badge'},
{name: 'launchType', label: 'Launch type', path: 'metadata.launchType', format: 'badge'},
{name: 'cluster', label: 'Cluster', path: 'metadata.cluster', format: 'code'},
@@ -16,24 +17,31 @@ export function awsEcsSchema(): ServiceSchema {
return {
cloud: 'aws', service: 'ecs', displayName: 'Amazon ECS',
fields: [
{name: 'cluster', label: 'Cluster', type: 'text', required: true, group: 'Required'},
{name: 'serviceName', label: 'Service name', type: 'text', required: true, group: 'Required'},
{name: 'taskDefinition', label: 'Task definition', type: 'text', required: true, group: 'Required'},
{name: 'launchType', label: 'Launch type', type: 'select', required: true, defaultValue: 'FARGATE', group: 'Required', options: [{label: 'Fargate', value: 'FARGATE'}, {label: 'EC2', value: 'EC2'}]},
{name: 'desiredCount', label: 'Desired tasks', type: 'text', required: false, defaultValue: '1', group: 'Required'},
{name: 'subnets', label: 'Subnet IDs', type: 'text', required: false, requiredWhen: {field: 'launchType', equals: 'FARGATE'}, group: 'awsvpc networking', span: true, description: 'Required for Fargate. Comma-separated subnets are used for awsvpc task ENIs.'},
{name: 'securityGroups', label: 'Security group IDs', type: 'text', required: false, group: 'awsvpc networking', span: true, description: 'Optional comma-separated groups for awsvpc tasks.'},
{name: 'assignPublicIp', label: 'Assign public IP', type: 'select', required: false, defaultValue: 'DISABLED', group: 'awsvpc networking', options: [{label: 'Disabled', value: 'DISABLED'}, {label: 'Enabled', value: 'ENABLED'}]},
{name: 'targetGroupArn', label: 'ALB target group ARN', type: 'text', required: false, group: 'Load balancing', span: true, description: 'Fargate awsvpc services must use an IP target group. The target health and attached ALB endpoint are shown after creation.'},
{name: 'containerName', label: 'Container name', type: 'text', required: false, group: 'Load balancing'},
{name: 'containerPort', label: 'Container port', type: 'text', required: false, group: 'Load balancing'},
{name: 'resourceKind', label: 'Create', type: 'select', required: true, defaultValue: 'service', group: 'Resource', options: [{label: 'Fargate service', value: 'service'}, {label: 'Cluster', value: 'cluster'}, {label: 'Fargate task definition', value: 'taskDefinition'}]},
{name: 'clusterName', label: 'New cluster name', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'cluster'}, visibleWhen: {field: 'resourceKind', values: ['cluster']}, group: 'Cluster'},
{name: 'family', label: 'Task family', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'taskDefinition'}, visibleWhen: {field: 'resourceKind', values: ['taskDefinition']}, group: 'Fargate task definition'},
{name: 'image', label: 'Container image', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'taskDefinition'}, visibleWhen: {field: 'resourceKind', values: ['taskDefinition']}, group: 'Fargate task definition', span: true},
{name: 'cpu', label: 'Task CPU units', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'taskDefinition'}, visibleWhen: {field: 'resourceKind', values: ['taskDefinition']}, defaultValue: '256', group: 'Fargate task definition'},
{name: 'memory', label: 'Task memory (MiB)', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'taskDefinition'}, visibleWhen: {field: 'resourceKind', values: ['taskDefinition']}, defaultValue: '512', group: 'Fargate task definition'},
{name: 'containerName', label: 'Container name', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'taskDefinition'}, visibleWhen: {field: 'resourceKind', values: ['taskDefinition', 'service']}, group: 'Container and target binding'},
{name: 'containerPort', label: 'Container port', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'taskDefinition'}, visibleWhen: {field: 'resourceKind', values: ['taskDefinition', 'service']}, group: 'Container and target binding'},
{name: 'cluster', label: 'Cluster', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'service'}, visibleWhen: {field: 'resourceKind', values: ['service']}, group: 'Fargate service'},
{name: 'serviceName', label: 'Service name', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'service'}, visibleWhen: {field: 'resourceKind', values: ['service']}, group: 'Fargate service'},
{name: 'taskDefinition', label: 'Task definition', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'service'}, visibleWhen: {field: 'resourceKind', values: ['service']}, group: 'Fargate service'},
{name: 'launchType', label: 'Launch type', type: 'select', required: false, visibleWhen: {field: 'resourceKind', values: ['service']}, defaultValue: 'FARGATE', group: 'Fargate service', options: [{label: 'Fargate', value: 'FARGATE'}, {label: 'EC2', value: 'EC2'}]},
{name: 'deploymentController', label: 'Deployment controller', type: 'select', required: false, visibleWhen: {field: 'resourceKind', values: ['service']}, defaultValue: 'ECS', group: 'Fargate service', options: [{label: 'ECS rolling deployment', value: 'ECS'}, {label: 'CodeDeploy blue/green', value: 'CODE_DEPLOY'}]},
{name: 'desiredCount', label: 'Desired tasks', type: 'text', required: false, visibleWhen: {field: 'resourceKind', values: ['service']}, defaultValue: '1', group: 'Fargate service'},
{name: 'subnets', label: 'Subnet IDs', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'service'}, visibleWhen: {field: 'resourceKind', values: ['service']}, group: 'awsvpc networking', span: true, description: 'Comma-separated subnets used for Fargate task ENIs.'},
{name: 'securityGroups', label: 'Security group IDs', type: 'text', required: false, visibleWhen: {field: 'resourceKind', values: ['service']}, group: 'awsvpc networking', span: true, description: 'Optional comma-separated groups for awsvpc tasks.'},
{name: 'assignPublicIp', label: 'Assign public IP', type: 'select', required: false, visibleWhen: {field: 'resourceKind', values: ['service']}, defaultValue: 'DISABLED', group: 'awsvpc networking', options: [{label: 'Disabled', value: 'DISABLED'}, {label: 'Enabled', value: 'ENABLED'}]},
{name: 'targetGroupArn', label: 'ALB target group ARN', type: 'text', required: false, visibleWhen: {field: 'resourceKind', values: ['service']}, group: 'Container and target binding', span: true, description: 'Optional. Fargate awsvpc services must use an IP target group; container name and port bind it to the task definition.'},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {resourceActions: [
{name: 'list', label: 'List ECS services', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect service health', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create EC2 or Fargate service', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Force delete service', enabled: true, status: 'available', runtimeRequired: true},
{name: 'list', label: 'List ECS resources', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect ECS resource', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create ECS resource', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete ECS resource', enabled: true, status: 'available', runtimeRequired: true},
]},
filters, columns,
}
@@ -46,18 +54,12 @@ export function awsFargateSchema(): ServiceSchema {
service: 'fargate',
displayName: 'AWS Fargate',
fields: ecs.fields
.filter((field) => field.name !== 'launchType')
.map((field) => field.name === 'subnets' ? {
...field,
required: true,
requiredWhen: undefined,
description: 'Required for Fargate awsvpc task ENIs. Comma-separated subnets are used for awsvpc networking.',
} : field),
.filter((field) => !['resourceKind', 'clusterName', 'family', 'image', 'cpu', 'memory', 'launchType'].includes(field.name))
.map((field) => ['cluster', 'serviceName', 'taskDefinition', 'subnets'].includes(field.name)
? {...field, required: true, requiredWhen: undefined}
: field),
capabilities: {
resourceActions: ecs.capabilities?.resourceActions?.map((action) => action.name === 'create' ? {
...action,
label: 'Create Fargate service',
} : action),
resourceActions: ecs.capabilities?.resourceActions?.map((action) => action.name === 'create' ? {...action, label: 'Create Fargate service'} : action),
},
}
}
@@ -0,0 +1,51 @@
import type {CapabilitySchema, FieldSchema, ResourceActionName, ServiceSchema} from './types'
const streamActions: CapabilitySchema<ResourceActionName>[] = [
{name: 'list', label: 'List streams', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create stream', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete stream', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect stream', enabled: true, status: 'available', runtimeRequired: false},
]
const streamFields: FieldSchema[] = [
{name: 'streamName', label: 'Stream name', type: 'text', required: true},
{
name: 'streamMode',
label: 'Capacity mode',
type: 'select',
required: true,
defaultValue: 'PROVISIONED',
options: [
{label: 'Provisioned', value: 'PROVISIONED'},
{label: 'On-demand', value: 'ON_DEMAND'},
],
},
{
name: 'shardCount',
label: 'Shard count',
type: 'text',
required: true,
defaultValue: '1',
visibleWhen: {field: 'streamMode', values: ['PROVISIONED']},
description: 'Required for provisioned streams.',
},
]
export function awsKinesisSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'kinesis',
displayName: 'Amazon Kinesis Data Streams',
fields: streamFields,
actions: ['list', 'create', 'inspect', 'delete'],
filters: [{name: 'search', label: 'Search', type: 'text', required: false}],
columns: [
{name: 'name', label: 'Stream name'},
{name: 'status', label: 'Status', format: 'badge'},
{name: 'mode', label: 'Capacity mode', path: 'metadata.streamMode'},
{name: 'shards', label: 'Open shards', path: 'metadata.openShardCount'},
{name: 'createdAt', label: 'Created at', format: 'datetime'},
],
capabilities: {resourceActions: streamActions},
}
}
@@ -44,6 +44,11 @@ export function awsNetworkingSchema(): ServiceSchema {
runtimeRequired: true,
},
],
itemActions: [
{name: 'list', label: 'Inspect ingress rules', enabled: true, status: 'available', runtimeRequired: true},
{name: 'put', label: 'Add ingress rule', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete ingress rule', enabled: true, status: 'available', runtimeRequired: true},
],
},
}
}
@@ -21,6 +21,7 @@ export type ServiceGroup =
| 'Databases'
| 'Networking'
| 'Integration'
| 'Analytics'
| 'Provisioning'
| 'Security'
| 'Observability'
@@ -32,6 +33,7 @@ export const SERVICE_GROUP_ORDER: ServiceGroup[] = [
'Databases',
'Networking',
'Integration',
'Analytics',
'Provisioning',
'Security',
'Observability',
@@ -171,6 +173,29 @@ export const SERVICE_CATALOG = {
group: 'Integration',
order: 15,
},
msk: {
displayName: 'Managed Streaming for Kafka',
displayNameByCloud: {aws: 'MSK'},
iconKey: 'msk',
group: 'Analytics',
order: 16,
legacyAvailability: {aws: 'available'},
},
'msk-connect': {
displayName: 'Managed Streaming for Kafka Connect',
displayNameByCloud: {aws: 'MSK Connect'},
iconKey: 'msk-connect',
group: 'Analytics',
order: 17,
legacyAvailability: {aws: 'available'},
},
kinesis: {
displayName: 'Kinesis Data Streams',
displayNameByCloud: {aws: 'Kinesis'},
iconKey: 'kinesis',
group: 'Analytics',
order: 18,
},
identity: {displayName: 'Identity', iconKey: 'iam', group: 'Security', order: 5},
apigateway: {displayName: 'API Gateway', iconKey: 'apigateway', group: 'Integration', order: 10},
email: {
+38
View File
@@ -85,6 +85,7 @@ export interface FieldSchema {
type: FieldType
required: boolean
requiredWhen?: {field: string; equals: string}
visibleWhen?: {field: string; values: string[]}
description?: string
group?: string
span?: boolean
@@ -697,6 +698,37 @@ export interface SqsLambdaEventSourceMappingOverview {
stateTransitionReason: string | null
}
export interface KinesisPutRecordInput {
partitionKey: string
data: string
}
export interface KinesisRecord {
sequenceNumber: string
partitionKey: string | null
data: string
approximateArrivalTimestamp: string | null
}
export interface LambdaEventSourceMappingInput {
functionName: string
eventSourceArn: string
batchSize?: number
startingPosition?: 'TRIM_HORIZON' | 'LATEST'
}
export interface LambdaEventSourceMappingOverview {
id: string
uuid: string
functionName: string | null
functionArn: string | null
eventSourceArn: string
batchSize: number | null
startingPosition: string | null
state: string | null
stateTransitionReason: string | null
}
export type KmsEncryptionAlgorithm =
| 'SYMMETRIC_DEFAULT'
| 'RSAES_OAEP_SHA_1'
@@ -791,6 +823,12 @@ export interface CloudServiceAdapter {
createSqsLambdaEventSourceMapping?(input: SqsLambdaEventSourceMappingInput): Promise<SqsLambdaEventSourceMappingOverview>
listSqsLambdaEventSourceMappings?(functionName: string): Promise<SqsLambdaEventSourceMappingOverview[]>
deleteSqsLambdaEventSourceMapping?(functionName: string, mappingId: string): Promise<void>
putKinesisRecord?(streamName: string, input: KinesisPutRecordInput): Promise<{sequenceNumber: string; shardId: string}>
listKinesisRecords?(streamName: string): Promise<KinesisRecord[]>
createLambdaEventSourceMapping?(input: LambdaEventSourceMappingInput): Promise<LambdaEventSourceMappingOverview>
listLambdaEventSourceMappings?(filter?: {functionName?: string; eventSourceArn?: string}): Promise<LambdaEventSourceMappingOverview[]>
updateLambdaEventSourceMapping?(mappingId: string, input: {batchSize?: number; enabled?: boolean}): Promise<LambdaEventSourceMappingOverview>
deleteLambdaEventSourceMapping?(mappingId: string): Promise<void>
encrypt?(id: string, input: KmsEncryptInput): Promise<KmsEncryptResult>
decrypt?(id: string, input: KmsDecryptInput): Promise<KmsDecryptResult>
// Lifecycle verbs. Optional because most categories have no notion of them;
+2
View File
@@ -44,6 +44,7 @@ import {AwsCodePipelineAdapter} from './adapter-aws/AwsCodePipelineAdapter'
import {AwsCodeDeployAdapter} from './adapter-aws/AwsCodeDeployAdapter'
import {AwsEcsAdapter} from './adapter-aws/AwsEcsAdapter'
import {AwsFargateAdapter} from './adapter-aws/AwsFargateAdapter'
import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2'
import {createEksService} from './services/eks'
@@ -90,6 +91,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsCodeDeployAdapter(clients.codedeploy, clients.lambda),
new AwsEcsAdapter(clients.ecs, clients.elbv2),
new AwsFargateAdapter(new AwsEcsAdapter(clients.ecs, clients.elbv2)),
new AwsKinesisAdapter(clients.kinesis),
new AzureStorageAdapter(),
new AzureServiceBusAdapter(),
new AzureDatabaseAdapter(),
+6
View File
@@ -7,6 +7,9 @@ import eks from "./routes/eks";
import ec2 from "./routes/ec2";
import secretsmanager from "./routes/secretsmanager";
import clouds from "./routes/clouds";
import msk from "./routes/msk";
import mskConnect from "./routes/mskConnect";
import ses from "./routes/ses";
const app = new Hono();
// Secrets Manager and KMS crypto routes handle sensitive values with server-side
@@ -43,6 +46,9 @@ app.route("/api/eks", eks);
app.route("/api/ec2", ec2);
app.route("/api/secretsmanager", secretsmanager);
app.route("/api/clouds", clouds);
app.route("/api/msk", msk);
app.route("/api/msk-connect", mskConnect);
app.route("/api/ses", ses);
// Serve static frontend files when public/ directory is present (production)
app.use("*", serveStatic({ root: "./public" }));
+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();
@@ -43,6 +43,10 @@ import type {
CloudFormationChangeSetInput,
SqsLambdaEventSourceMappingInput,
SqsLambdaEventSourceMappingOverview,
KinesisPutRecordInput,
KinesisRecord,
LambdaEventSourceMappingInput,
LambdaEventSourceMappingOverview,
SqsMessage,
SqsMoveTaskInput,
SqsMoveTaskResult,
@@ -439,6 +443,42 @@ export class CloudProxyService {
await adapter.deleteSqsLambdaEventSourceMapping(functionName, mappingId)
}
async putKinesisRecord(cloud: CloudProvider, streamName: string, input: KinesisPutRecordInput): Promise<{sequenceNumber: string; shardId: string}> {
const adapter = this.requireAdapter(cloud, 'kinesis')
if (!adapter.putKinesisRecord) throw new NotSupportedError(`Kinesis record production is not supported for ${cloud}`)
return adapter.putKinesisRecord(streamName, input)
}
async listKinesisRecords(cloud: CloudProvider, streamName: string): Promise<KinesisRecord[]> {
const adapter = this.requireAdapter(cloud, 'kinesis')
if (!adapter.listKinesisRecords) throw new NotSupportedError(`Kinesis record inspection is not supported for ${cloud}`)
return adapter.listKinesisRecords(streamName)
}
async createLambdaEventSourceMapping(cloud: CloudProvider, input: LambdaEventSourceMappingInput): Promise<LambdaEventSourceMappingOverview> {
const adapter = this.requireAdapter(cloud, 'serverless')
if (!adapter.createLambdaEventSourceMapping) throw new NotSupportedError(`Lambda event source mappings are not supported for ${cloud}`)
return adapter.createLambdaEventSourceMapping(input)
}
async listLambdaEventSourceMappings(cloud: CloudProvider, filter?: {functionName?: string; eventSourceArn?: string}): Promise<LambdaEventSourceMappingOverview[]> {
const adapter = this.requireAdapter(cloud, 'serverless')
if (!adapter.listLambdaEventSourceMappings) throw new NotSupportedError(`Lambda event source mappings are not supported for ${cloud}`)
return adapter.listLambdaEventSourceMappings(filter)
}
async updateLambdaEventSourceMapping(cloud: CloudProvider, mappingId: string, input: {batchSize?: number; enabled?: boolean}): Promise<LambdaEventSourceMappingOverview> {
const adapter = this.requireAdapter(cloud, 'serverless')
if (!adapter.updateLambdaEventSourceMapping) throw new NotSupportedError(`Lambda event source mappings are not supported for ${cloud}`)
return adapter.updateLambdaEventSourceMapping(mappingId, input)
}
async deleteLambdaEventSourceMapping(cloud: CloudProvider, mappingId: string): Promise<void> {
const adapter = this.requireAdapter(cloud, 'serverless')
if (!adapter.deleteLambdaEventSourceMapping) throw new NotSupportedError(`Lambda event source mappings are not supported for ${cloud}`)
await adapter.deleteLambdaEventSourceMapping(mappingId)
}
async sendSqsMessage(cloud: CloudProvider, queueId: string, input: SqsSendMessageInput): Promise<{messageId: string}> {
const adapter = this.requireAdapter(cloud, 'messaging')
if (!adapter.sendSqsMessage) throw new NotSupportedError(`SQS message sending is not supported for ${cloud}`)
+16
View File
@@ -43,6 +43,7 @@ import {
DisassociateRouteTableCommand,
GetConsoleOutputCommand,
ModifySubnetAttributeCommand,
ModifyInstanceAttributeCommand,
ModifyVpcAttributeCommand,
RebootInstancesCommand,
ReleaseAddressCommand,
@@ -140,6 +141,14 @@ export type Ec2Instance = {
securityGroups: Ec2SecurityGroupRef[]
networkInterfaces: Ec2NetworkInterface[]
tags: Ec2Tag[]
connection?: Ec2InstanceConnection
}
export type Ec2InstanceConnection = {
sshHost?: string
sshPort?: number
containerId?: string
containerStatus: 'running' | 'stopped' | 'unavailable'
}
export type Ec2KeyPair = {
@@ -572,6 +581,13 @@ export function createEc2Service(client: EC2Client = awsClients.ec2) {
}
},
async updateInstanceSecurityGroups(instanceId: string, groupIds: string[]): Promise<void> {
await client.send(new ModifyInstanceAttributeCommand({
InstanceId: instanceId,
Groups: groupIds,
}))
},
async createImage(input: CreateAmiInput): Promise<{imageId: string}> {
const res = await client.send(new CreateImageCommand({
InstanceId: input.instanceId,
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, test } from "bun:test";
import {
CreateClusterCommand,
GetBootstrapBrokersCommand,
ListClustersCommand,
type KafkaClient,
} from "@aws-sdk/client-kafka";
import { createMskService, type KafkaClientFactory } from "./msk";
function createAwsClient() {
const commands: unknown[] = [];
const client = {
send: async (command: unknown) => {
commands.push(command);
if (command instanceof GetBootstrapBrokersCommand) {
return { BootstrapBrokerString: "broker-1:9092,broker-2:9092" };
}
if (command instanceof ListClustersCommand) {
return { ClusterInfoList: [{ ClusterName: "orders" }] };
}
return { ClusterName: "orders", ClusterArn: "arn:cluster:orders" };
},
} as unknown as KafkaClient;
return { client, commands };
}
function createKafkaFactory() {
const calls: string[] = [];
const factory: KafkaClientFactory = () => ({
admin: () => ({
connect: async () => { calls.push("admin.connect"); },
disconnect: async () => { calls.push("admin.disconnect"); },
fetchTopicMetadata: async () => ({ topics: [{ name: "orders", partitions: [{}, {}] }] }),
createTopics: async () => { calls.push("admin.createTopics"); return true; },
deleteTopics: async () => { calls.push("admin.deleteTopics"); },
fetchTopicOffsets: async () => [{ partition: 0, offset: "12" }],
fetchOffsets: async () => [{ topic: "orders", partitions: [{ partition: 0, offset: "7" }] }],
}),
producer: () => ({
connect: async () => { calls.push("producer.connect"); },
disconnect: async () => { calls.push("producer.disconnect"); },
send: async () => [{ topicName: "orders", partition: 0, baseOffset: "12" }],
}),
consumer: () => ({
connect: async () => { calls.push("consumer.connect"); },
disconnect: async () => { calls.push("consumer.disconnect"); },
subscribe: async () => { calls.push("consumer.subscribe"); },
run: async ({ eachMessage }) => {
await eachMessage({
partition: 0,
message: {
key: Buffer.from("key"),
value: Buffer.from("value"),
headers: { source: Buffer.from("test") },
offset: "12",
timestamp: "123",
},
});
},
stop: async () => { calls.push("consumer.stop"); },
}),
});
return { factory, calls };
}
describe("mskService", () => {
test("lists clusters and creates clusters with the MSK SDK", async () => {
const { client, commands } = createAwsClient();
const service = createMskService(client, createKafkaFactory().factory);
expect(await service.listClusters()).toEqual([{ ClusterName: "orders" }]);
await service.createCluster({
clusterName: "orders",
kafkaVersion: "3.6.x",
numberOfBrokerNodes: 3,
brokerNodeGroupInfo: { InstanceType: "kafka.m5.large", ClientSubnets: ["subnet-1", "subnet-2"] },
});
expect(commands[0]).toBeInstanceOf(ListClustersCommand);
expect(commands[1]).toBeInstanceOf(CreateClusterCommand);
expect((commands[1] as CreateClusterCommand).input).toMatchObject({ ClusterName: "orders" });
});
test("uses server-resolved bootstrap brokers for topic and message operations", async () => {
const { client, commands } = createAwsClient();
const { factory, calls } = createKafkaFactory();
const service = createMskService(client, factory);
expect(await service.listTopics("arn:cluster:orders")).toEqual([{ name: "orders", partitionCount: 2 }]);
await service.createTopic("arn:cluster:orders", { topic: "events", numPartitions: 1, replicationFactor: 1 });
await service.deleteTopic("arn:cluster:orders", "events");
expect(await service.produceMessage("arn:cluster:orders", "orders", { value: "hello" })).toEqual([
{ topicName: "orders", partition: 0, baseOffset: "12" },
]);
expect(commands.filter((command) => command instanceof GetBootstrapBrokersCommand)).toHaveLength(4);
expect(calls).toContain("admin.createTopics");
expect(calls).toContain("producer.disconnect");
});
test("peeks without committing and calculates consumer group lag", async () => {
const { client } = createAwsClient();
const { factory, calls } = createKafkaFactory();
const service = createMskService(client, factory);
expect(await service.peekMessages("arn:cluster:orders", "orders", 1)).toEqual([
{
partition: 0,
offset: "12",
timestamp: "123",
key: "key",
value: "value",
headers: { source: "test" },
},
]);
expect(await service.consumerGroupLag("arn:cluster:orders", "orders-workers")).toEqual([
{
topic: "orders",
partitions: [{ partition: 0, currentOffset: "7", endOffset: "12", lag: 5 }],
totalLag: 5,
},
]);
expect(calls).toContain("consumer.stop");
expect(calls).toContain("consumer.disconnect");
});
});
+273
View File
@@ -0,0 +1,273 @@
import {
CreateClusterCommand,
DeleteClusterCommand,
GetBootstrapBrokersCommand,
KafkaClient,
ListClustersCommand,
type BrokerNodeGroupInfo,
} from "@aws-sdk/client-kafka";
import { Kafka } from "kafkajs";
import { awsClients } from "../aws";
export type CreateMskClusterInput = {
clusterName: string;
kafkaVersion: string;
numberOfBrokerNodes: number;
brokerNodeGroupInfo: BrokerNodeGroupInfo;
tags?: Record<string, string>;
};
export type CreateTopicInput = {
topic: string;
numPartitions?: number;
replicationFactor?: number;
configEntries?: { name: string; value: string }[];
};
export type ProduceMessageInput = {
key?: string;
value: string;
headers?: Record<string, string>;
};
type KafkaAdmin = {
connect(): Promise<void>;
disconnect(): Promise<void>;
fetchTopicMetadata(input?: { topics?: string[] }): Promise<{
topics: { name: string; partitions: unknown[] }[];
}>;
createTopics(input: { topics: CreateTopicInput[] }): Promise<boolean>;
deleteTopics(input: { topics: string[] }): Promise<void>;
fetchTopicOffsets(topic: string): Promise<{ partition: number; offset: string }[]>;
fetchOffsets(input: {
groupId: string;
topics?: string[];
}): Promise<{ topic: string; partitions: { partition: number; offset: string }[] }[]>;
};
type KafkaProducer = {
connect(): Promise<void>;
disconnect(): Promise<void>;
send(input: {
topic: string;
messages: { key?: string; value: string; headers?: Record<string, string> }[];
}): Promise<{ topicName: string; partition: number; baseOffset?: string }[]>;
};
type KafkaConsumer = {
connect(): Promise<void>;
disconnect(): Promise<void>;
subscribe(input: { topic: string; fromBeginning: boolean }): Promise<void>;
run(input: {
autoCommit: boolean;
eachMessage: (payload: {
partition: number;
message: {
key: Buffer | null;
value: Buffer | null;
headers?: Record<string, Buffer>;
offset: string;
timestamp: string;
};
}) => Promise<void>;
}): Promise<void>;
stop(): Promise<void>;
};
export type KafkaClientFactory = (brokers: string[]) => {
admin(): KafkaAdmin;
producer(): KafkaProducer;
consumer(input: { groupId: string }): KafkaConsumer;
};
function createKafkaClient(brokers: string[]) {
return new Kafka({ brokers }) as unknown as ReturnType<KafkaClientFactory>;
}
function splitBootstrapBrokers(bootstrapBrokers: string): string[] {
return bootstrapBrokers.split(",").map((broker) => broker.trim()).filter(Boolean);
}
export function createMskService(
client: KafkaClient = awsClients.kafka,
kafkaClientFactory: KafkaClientFactory = createKafkaClient,
) {
async function brokers(clusterArn: string): Promise<string[]> {
const command = new GetBootstrapBrokersCommand({ ClusterArn: clusterArn });
command.middlewareStack.add(
(next) => async (args) => {
const request = args.request as { headers: Record<string, string> };
request.headers["x-floci-internal-client"] = "true";
return next(args);
},
{ step: "build", name: "flociInternalMskClient" },
);
const response = await client.send(command);
const bootstrapBrokers = response.BootstrapBrokerString ?? response.BootstrapBrokerStringTls;
if (!bootstrapBrokers) {
throw new Error(`MSK cluster ${clusterArn} did not return bootstrap brokers`);
}
return splitBootstrapBrokers(bootstrapBrokers);
}
return {
async listClusters() {
const clusters = [];
let nextToken: string | undefined;
do {
const response = await client.send(new ListClustersCommand({ NextToken: nextToken }));
clusters.push(...(response.ClusterInfoList ?? []));
nextToken = response.NextToken;
} while (nextToken);
return clusters;
},
async createCluster(input: CreateMskClusterInput) {
const response = await client.send(new CreateClusterCommand({
ClusterName: input.clusterName,
KafkaVersion: input.kafkaVersion,
NumberOfBrokerNodes: input.numberOfBrokerNodes,
BrokerNodeGroupInfo: input.brokerNodeGroupInfo,
Tags: input.tags,
}));
return response;
},
async deleteCluster(clusterArn: string) {
return client.send(new DeleteClusterCommand({ ClusterArn: clusterArn }));
},
async getBootstrapBrokers(clusterArn: string) {
const response = await client.send(new GetBootstrapBrokersCommand({ ClusterArn: clusterArn }));
return response.BootstrapBrokerString ?? response.BootstrapBrokerStringTls;
},
async listTopics(clusterArn: string) {
const admin = kafkaClientFactory(await brokers(clusterArn)).admin();
await admin.connect();
try {
const metadata = await admin.fetchTopicMetadata();
return metadata.topics
.filter((topic) => !topic.name.startsWith("__"))
.map((topic) => ({
name: topic.name,
partitionCount: topic.partitions.length,
}));
} finally {
await admin.disconnect();
}
},
async createTopic(clusterArn: string, input: CreateTopicInput) {
const admin = kafkaClientFactory(await brokers(clusterArn)).admin();
await admin.connect();
try {
await admin.createTopics({ topics: [input] });
} finally {
await admin.disconnect();
}
},
async deleteTopic(clusterArn: string, topic: string) {
const admin = kafkaClientFactory(await brokers(clusterArn)).admin();
await admin.connect();
try {
await admin.deleteTopics({ topics: [topic] });
} finally {
await admin.disconnect();
}
},
async produceMessage(clusterArn: string, topic: string, input: ProduceMessageInput) {
const producer = kafkaClientFactory(await brokers(clusterArn)).producer();
await producer.connect();
try {
return await producer.send({ topic, messages: [input] });
} finally {
await producer.disconnect();
}
},
async peekMessages(clusterArn: string, topic: string, limit = 50) {
const consumer = kafkaClientFactory(await brokers(clusterArn)).consumer({
groupId: `floci-ui-peek-${crypto.randomUUID()}`,
});
const messages: { partition: number; offset: string; timestamp: string; key?: string; value?: string; headers: Record<string, string> }[] = [];
await consumer.connect();
try {
await consumer.subscribe({ topic, fromBeginning: true });
await new Promise<void>(async (resolve, reject) => {
const timeout = setTimeout(resolve, 500);
try {
await consumer.run({
autoCommit: false,
eachMessage: async ({ partition, message }) => {
if (messages.length >= limit) {
return;
}
messages.push({
partition,
offset: message.offset,
timestamp: message.timestamp,
key: message.key?.toString(),
value: message.value?.toString(),
headers: Object.fromEntries(
Object.entries(message.headers ?? {}).map(([name, value]) => [name, value.toString()]),
),
});
if (messages.length >= limit) {
clearTimeout(timeout);
resolve();
}
},
});
} catch (error) {
clearTimeout(timeout);
reject(error);
}
});
return messages;
} finally {
await consumer.stop();
await consumer.disconnect();
}
},
async consumerGroupLag(clusterArn: string, groupId: string, topic?: string) {
const admin = kafkaClientFactory(await brokers(clusterArn)).admin();
await admin.connect();
let completed = false;
try {
const offsets = await admin.fetchOffsets({ groupId, topics: topic ? [topic] : undefined });
const lag = await Promise.all(offsets.map(async (topicOffsets) => {
const endOffsets = await admin.fetchTopicOffsets(topicOffsets.topic);
const endOffsetByPartition = new Map(endOffsets.map((offset) => [offset.partition, offset.offset]));
const partitions = topicOffsets.partitions.map((offset) => ({
partition: offset.partition,
currentOffset: offset.offset,
endOffset: endOffsetByPartition.get(offset.partition) ?? "0",
lag: Math.max(0, Number(endOffsetByPartition.get(offset.partition) ?? 0) - Math.max(0, Number(offset.offset))),
}));
return {
topic: topicOffsets.topic,
partitions,
totalLag: partitions.reduce((total, partition) => total + partition.lag, 0),
};
}));
completed = true;
return lag;
} finally {
try {
await admin.disconnect();
} catch (ignored) {
// KafkaJS can close the coordinator socket after returning offsets.
if (!completed) {
throw ignored;
}
}
}
},
};
}
export const mskService = createMskService();
@@ -0,0 +1,32 @@
import {describe, expect, test} from "bun:test";
import {CreateConnectorCommand, CreateCustomPluginCommand, DeleteConnectorCommand, DescribeConnectorCommand, ListConnectorsCommand, ListCustomPluginsCommand, UpdateConnectorCommand, type KafkaConnectClient} from "@aws-sdk/client-kafkaconnect";
import {createMskConnectService} from "./mskConnect";
test("uses Kafka Connect SDK commands with AWS request shapes", async () => {
const commands: unknown[] = [];
const client = {send: async (command: unknown) => {
commands.push(command);
if (command instanceof ListCustomPluginsCommand) return {customPlugins: [{name: "s3-sink"}]};
if (command instanceof ListConnectorsCommand) return {connectors: [{connectorName: "archive"}]};
return {connectorArn: "arn:aws:kafkaconnect:local:connector/archive"};
}} as unknown as KafkaConnectClient;
const service = createMskConnectService(client);
expect(await service.listCustomPlugins()).toEqual([{name: "s3-sink"}]);
expect(await service.listConnectors()).toEqual([{connectorName: "archive"}]);
await service.createCustomPlugin({name: "s3-sink", contentType: "JAR", location: {s3Location: {bucketArn: "arn:aws:s3:::plugins", fileKey: "s3.zip"}}});
await service.createConnector({connectorName: "archive", capacity: {provisionedCapacity: {mcuCount: 1, workerCount: 1}}, connectorConfiguration: {topics: "orders"}, kafkaCluster: {apacheKafkaCluster: {bootstrapServers: "broker:9092", vpc: {securityGroups: ["sg-local"], subnets: ["subnet-local"]}}}, kafkaClusterClientAuthentication: {authenticationType: "NONE"}, kafkaClusterEncryptionInTransit: {encryptionType: "PLAINTEXT"}, kafkaConnectVersion: "3.7.x", plugins: [{customPlugin: {customPluginArn: "arn:plugin", revision: 1}}], serviceExecutionRoleArn: "arn:role"});
await service.describeConnector("arn:connector");
await service.updateConnector({connectorArn: "arn:connector", currentVersion: "1", connectorConfiguration: {topics: "events"}});
await service.deleteConnector("arn:connector");
expect(commands[0]).toBeInstanceOf(ListCustomPluginsCommand);
expect(commands[1]).toBeInstanceOf(ListConnectorsCommand);
expect(commands[2]).toBeInstanceOf(CreateCustomPluginCommand);
expect((commands[2] as CreateCustomPluginCommand).input).toMatchObject({contentType: "JAR", location: {s3Location: {fileKey: "s3.zip"}}});
expect(commands[3]).toBeInstanceOf(CreateConnectorCommand);
expect((commands[3] as CreateConnectorCommand).input).toMatchObject({kafkaClusterClientAuthentication: {authenticationType: "NONE"}, plugins: [{customPlugin: {revision: 1}}]});
expect(commands[4]).toBeInstanceOf(DescribeConnectorCommand);
expect(commands[5]).toBeInstanceOf(UpdateConnectorCommand);
expect(commands[6]).toBeInstanceOf(DeleteConnectorCommand);
});
+67
View File
@@ -0,0 +1,67 @@
import {
CreateConnectorCommand,
CreateCustomPluginCommand,
DeleteConnectorCommand,
DeleteCustomPluginCommand,
DescribeConnectorCommand,
KafkaConnectClient,
ListConnectorsCommand,
ListCustomPluginsCommand,
UpdateConnectorCommand,
type CreateConnectorRequest,
type CreateCustomPluginRequest,
type UpdateConnectorRequest,
} from "@aws-sdk/client-kafkaconnect";
import { awsClients } from "../aws";
export function createMskConnectService(client: KafkaConnectClient = awsClients.kafkaConnect) {
return {
async listCustomPlugins() {
const plugins = [];
let nextToken: string | undefined;
do {
const response = await client.send(new ListCustomPluginsCommand({ nextToken }));
plugins.push(...(response.customPlugins ?? []));
nextToken = response.nextToken;
} while (nextToken);
return plugins;
},
createCustomPlugin(input: CreateCustomPluginRequest) {
return client.send(new CreateCustomPluginCommand(input));
},
deleteCustomPlugin(customPluginArn: string) {
return client.send(new DeleteCustomPluginCommand({ customPluginArn }));
},
async listConnectors() {
const connectors = [];
let nextToken: string | undefined;
do {
const response = await client.send(new ListConnectorsCommand({ nextToken }));
connectors.push(...(response.connectors ?? []));
nextToken = response.nextToken;
} while (nextToken);
return connectors;
},
async describeConnector(connectorArn: string) {
return client.send(new DescribeConnectorCommand({ connectorArn }));
},
createConnector(input: CreateConnectorRequest) {
return client.send(new CreateConnectorCommand(input));
},
deleteConnector(connectorArn: string) {
return client.send(new DeleteConnectorCommand({ connectorArn }));
},
updateConnector(input: UpdateConnectorRequest) {
return client.send(new UpdateConnectorCommand(input));
},
};
}
export const mskConnectService = createMskConnectService();
+95
View File
@@ -0,0 +1,95 @@
import {describe, expect, test} from "bun:test";
import {ListMetricsCommand, type CloudWatchClient} from "@aws-sdk/client-cloudwatch";
import {ListEventBusesCommand, type EventBridgeClient} from "@aws-sdk/client-eventbridge";
import {
CreateConfigurationSetEventDestinationCommand,
CreateEmailIdentityCommand,
DeleteEmailTemplateCommand,
ListSuppressedDestinationsCommand,
SendEmailCommand,
TestRenderEmailTemplateCommand,
type SESv2Client,
} from "@aws-sdk/client-sesv2";
import {ListTopicsCommand, type SNSClient} from "@aws-sdk/client-sns";
import {createSesService} from "./ses";
describe("sesService", () => {
test("uses SESv2 identity, email, template, and event destination command contracts", async () => {
const commands: unknown[] = [];
const sesv2 = {
send: async (command: unknown) => {
commands.push(command);
if (command instanceof ListSuppressedDestinationsCommand) return {SuppressedDestinationSummaries: []};
return {MessageId: "message-1"};
},
} as unknown as SESv2Client;
const service = createSesService(sesv2, {} as SNSClient, {} as EventBridgeClient, {} as CloudWatchClient);
await service.createIdentity({EmailIdentity: "sender@example.test"});
await service.sendSimple({
FromEmailAddress: "sender@example.test",
Destination: {ToAddresses: ["recipient@example.test"]},
Content: {Simple: {Subject: {Data: "Hello"}, Body: {Text: {Data: "Hi"}}}},
});
await service.sendRaw({
FromEmailAddress: "sender@example.test",
Destination: {ToAddresses: ["recipient@example.test"]},
Content: {Raw: {Data: "U3ViamVjdDogSGVsbG8="}},
});
await service.testRenderTemplate({TemplateName: "welcome", TemplateData: "{\"name\":\"Ada\"}"});
await service.createEventDestination("delivery-events", {
EventDestinationName: "to-events",
EventDestination: {Enabled: true, MatchingEventTypes: ["SEND"], EventBridgeDestination: {EventBusArn: "arn:aws:events:us-east-1:000000000000:event-bus/default"}},
});
await service.deleteTemplate("welcome");
expect(commands[0]).toBeInstanceOf(CreateEmailIdentityCommand);
expect((commands[0] as CreateEmailIdentityCommand).input).toEqual({EmailIdentity: "sender@example.test"});
expect(commands[1]).toBeInstanceOf(SendEmailCommand);
expect((commands[2] as SendEmailCommand).input.Content?.Raw?.Data).toEqual(Uint8Array.from([83, 117, 98, 106, 101, 99, 116, 58, 32, 72, 101, 108, 108, 111]));
expect(commands[3]).toBeInstanceOf(TestRenderEmailTemplateCommand);
expect(commands[4]).toBeInstanceOf(CreateConfigurationSetEventDestinationCommand);
expect((commands[4] as CreateConfigurationSetEventDestinationCommand).input).toMatchObject({ConfigurationSetName: "delivery-events", EventDestinationName: "to-events"});
expect(commands[5]).toBeInstanceOf(DeleteEmailTemplateCommand);
});
test("lists live destination inventory with the AWS SDK", async () => {
const snsCommands: unknown[] = [];
const eventBridgeCommands: unknown[] = [];
const cloudWatchCommands: unknown[] = [];
const service = createSesService(
{} as SESv2Client,
{send: async (command: unknown) => {
snsCommands.push(command);
return {Topics: [{TopicArn: "arn:aws:sns:us-east-1:000000000000:delivery"}]};
}} as unknown as SNSClient,
{send: async (command: unknown) => {
eventBridgeCommands.push(command);
return {EventBuses: [{Name: "default"}]};
}} as unknown as EventBridgeClient,
{send: async (command: unknown) => {
cloudWatchCommands.push(command);
return {Metrics: [{MetricName: "Send"}]};
}} as unknown as CloudWatchClient,
);
expect(await service.listSnsTopics()).toEqual([{TopicArn: "arn:aws:sns:us-east-1:000000000000:delivery"}]);
expect(await service.listEventBuses()).toEqual([{Name: "default"}]);
expect(await service.listSesMetrics()).toEqual([{MetricName: "Send"}]);
expect(snsCommands[0]).toBeInstanceOf(ListTopicsCommand);
expect(eventBridgeCommands[0]).toBeInstanceOf(ListEventBusesCommand);
expect((cloudWatchCommands[0] as ListMetricsCommand).input).toEqual({Namespace: "AWS/SES", NextToken: undefined});
});
test("normalizes Floci configuration-set names for the console", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => new Response(JSON.stringify({ConfigurationSets: ["mail-events"]}), {headers: {"content-type": "application/json"}})) as unknown as typeof fetch;
try {
const service = createSesService({} as SESv2Client, {} as SNSClient, {} as EventBridgeClient, {} as CloudWatchClient);
expect(await service.listConfigurationSets()).toEqual([{ConfigurationSetName: "mail-events"}]);
} finally {
globalThis.fetch = originalFetch;
}
});
});
+209
View File
@@ -0,0 +1,209 @@
import {CloudWatchClient, ListMetricsCommand} from "@aws-sdk/client-cloudwatch";
import {EventBridgeClient, ListEventBusesCommand} from "@aws-sdk/client-eventbridge";
import {
CreateConfigurationSetCommand,
CreateConfigurationSetEventDestinationCommand,
CreateEmailIdentityCommand,
CreateEmailTemplateCommand,
DeleteConfigurationSetCommand,
DeleteConfigurationSetEventDestinationCommand,
DeleteEmailIdentityCommand,
DeleteEmailTemplateCommand,
DeleteSuppressedDestinationCommand,
GetConfigurationSetEventDestinationsCommand,
GetEmailTemplateCommand,
ListEmailTemplatesCommand,
ListSuppressedDestinationsCommand,
PutSuppressedDestinationCommand,
SESv2Client,
SendEmailCommand,
TestRenderEmailTemplateCommand,
UpdateEmailTemplateCommand,
type CreateConfigurationSetCommandInput,
type CreateConfigurationSetEventDestinationCommandInput,
type CreateEmailIdentityCommandInput,
type CreateEmailTemplateCommandInput,
type PutSuppressedDestinationCommandInput,
type SendEmailCommandInput,
type TestRenderEmailTemplateCommandInput,
type UpdateEmailTemplateCommandInput,
} from "@aws-sdk/client-sesv2";
import {ListTopicsCommand, SNSClient, type Topic} from "@aws-sdk/client-sns";
import {awsClients} from "../aws";
export type RawEmailInput = Omit<SendEmailCommandInput, "Content"> & {
Content: {Raw: {Data: string}};
};
export function createSesService(
sesv2: SESv2Client = awsClients.sesv2,
sns: SNSClient = awsClients.sns,
eventbridge: EventBridgeClient = awsClients.eventbridge,
cloudwatch: CloudWatchClient = awsClients.cloudwatch,
) {
return {
async listIdentities() {
const identities = [];
let NextToken: string | undefined;
do {
const response = await sesRestRequest<{EmailIdentities?: unknown[]; NextToken?: string}>("/v2/email/identities", NextToken);
identities.push(...(response.EmailIdentities ?? []));
NextToken = response.NextToken;
} while (NextToken);
return identities;
},
createIdentity(input: CreateEmailIdentityCommandInput) {
return sesv2.send(new CreateEmailIdentityCommand(input));
},
deleteIdentity(EmailIdentity: string) {
return sesv2.send(new DeleteEmailIdentityCommand({EmailIdentity}));
},
sendSimple(input: SendEmailCommandInput) {
return sesv2.send(new SendEmailCommand(input));
},
sendRaw(input: RawEmailInput) {
return sesv2.send(new SendEmailCommand({
...input,
Content: {Raw: {Data: Uint8Array.fromBase64(input.Content.Raw.Data)}},
}));
},
sendTemplate(input: SendEmailCommandInput) {
return sesv2.send(new SendEmailCommand(input));
},
async listSuppressedDestinations() {
const destinations = [];
let NextToken: string | undefined;
do {
const response = await sesv2.send(new ListSuppressedDestinationsCommand({NextToken}));
destinations.push(...(response.SuppressedDestinationSummaries ?? []));
NextToken = response.NextToken;
} while (NextToken);
return destinations;
},
createSuppression(input: PutSuppressedDestinationCommandInput) {
return sesv2.send(new PutSuppressedDestinationCommand(input));
},
deleteSuppression(EmailAddress: string) {
return sesv2.send(new DeleteSuppressedDestinationCommand({EmailAddress}));
},
async listTemplates() {
const templates = [];
let NextToken: string | undefined;
do {
const response = await sesv2.send(new ListEmailTemplatesCommand({NextToken}));
templates.push(...(response.TemplatesMetadata ?? []));
NextToken = response.NextToken;
} while (NextToken);
return templates;
},
createTemplate(input: CreateEmailTemplateCommandInput) {
return sesv2.send(new CreateEmailTemplateCommand(input));
},
updateTemplate(TemplateName: string, input: Omit<UpdateEmailTemplateCommandInput, "TemplateName">) {
return sesv2.send(new UpdateEmailTemplateCommand({...input, TemplateName}));
},
deleteTemplate(TemplateName: string) {
return sesv2.send(new DeleteEmailTemplateCommand({TemplateName}));
},
getTemplate(TemplateName: string) {
return sesv2.send(new GetEmailTemplateCommand({TemplateName}));
},
testRenderTemplate(input: TestRenderEmailTemplateCommandInput) {
return sesv2.send(new TestRenderEmailTemplateCommand(input));
},
async listConfigurationSets() {
const configurationSets = [];
let NextToken: string | undefined;
do {
const response = await sesRestRequest<{ConfigurationSets?: unknown[]; NextToken?: string}>("/v2/email/configuration-sets", NextToken);
configurationSets.push(...(response.ConfigurationSets ?? []).map((configurationSet) => typeof configurationSet === "string" ? {ConfigurationSetName: configurationSet} : configurationSet));
NextToken = response.NextToken;
} while (NextToken);
return configurationSets;
},
createConfigurationSet(input: CreateConfigurationSetCommandInput) {
return sesv2.send(new CreateConfigurationSetCommand(input));
},
deleteConfigurationSet(ConfigurationSetName: string) {
return sesv2.send(new DeleteConfigurationSetCommand({ConfigurationSetName}));
},
async listEventDestinations(ConfigurationSetName: string) {
const response = await sesv2.send(new GetConfigurationSetEventDestinationsCommand({ConfigurationSetName}));
return response.EventDestinations ?? [];
},
createEventDestination(ConfigurationSetName: string, input: Omit<CreateConfigurationSetEventDestinationCommandInput, "ConfigurationSetName">) {
return sesv2.send(new CreateConfigurationSetEventDestinationCommand({...input, ConfigurationSetName}));
},
deleteEventDestination(ConfigurationSetName: string, EventDestinationName: string) {
return sesv2.send(new DeleteConfigurationSetEventDestinationCommand({ConfigurationSetName, EventDestinationName}));
},
async listSnsTopics() {
const topics: Topic[] = [];
let NextToken: string | undefined;
do {
const response = await sns.send(new ListTopicsCommand({NextToken}));
topics.push(...(response.Topics ?? []));
NextToken = response.NextToken;
} while (NextToken);
return topics;
},
async listEventBuses() {
const eventBuses = [];
let NextToken: string | undefined;
do {
const response = await eventbridge.send(new ListEventBusesCommand({NextToken}));
eventBuses.push(...(response.EventBuses ?? []));
NextToken = response.NextToken;
} while (NextToken);
return eventBuses;
},
async listSesMetrics() {
const metrics = [];
let NextToken: string | undefined;
do {
const response = await cloudwatch.send(new ListMetricsCommand({Namespace: "AWS/SES", NextToken}));
metrics.push(...(response.Metrics ?? []));
NextToken = response.NextToken;
} while (NextToken);
return metrics;
},
};
}
export const sesService = createSesService();
async function sesRestRequest<T>(path: string, nextToken?: string): Promise<T> {
const endpoint = process.env.FLOCI_ENDPOINT ?? "http://localhost:4566";
const url = new URL(path, endpoint);
if (nextToken) {
url.searchParams.set("NextToken", nextToken);
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`SES REST request failed with HTTP ${response.status}`);
}
return response.json() as Promise<T>;
}