feat: MSK, MSK CONNECT and SES
This commit is contained in:
@@ -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}]}]}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user