feat: MSK, MSK CONNECT and SES
This commit is contained in:
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -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}]}]}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'})
|
||||
|
||||
@@ -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'>>()
|
||||
|
||||
@@ -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: [] }]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -58,7 +58,13 @@ export const apiEndpointKeys = {
|
||||
updateCloudFormationTerminationProtection: "clouds.services.resources.iac.termination-protection.update",
|
||||
createSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.create",
|
||||
listSqsLambdaEventSourceMappings: "clouds.services.resources.serverless.sqs-event-source-mappings.list",
|
||||
deleteSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.delete",
|
||||
deleteSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.delete",
|
||||
putKinesisRecord: "clouds.services.resources.kinesis.record.put",
|
||||
listKinesisRecords: "clouds.services.resources.kinesis.records.list",
|
||||
createLambdaEventSourceMapping: "clouds.services.resources.serverless.event-source-mapping.create",
|
||||
listLambdaEventSourceMappings: "clouds.services.resources.serverless.event-source-mappings.list",
|
||||
updateLambdaEventSourceMapping: "clouds.services.resources.serverless.event-source-mapping.update",
|
||||
deleteLambdaEventSourceMapping: "clouds.services.resources.serverless.event-source-mapping.delete",
|
||||
startWorkflowExecution: "clouds.services.resources.workflows.execution.start",
|
||||
listWorkflowExecutions: "clouds.services.resources.workflows.executions.list",
|
||||
describeWorkflowExecution: "clouds.services.resources.workflows.execution.describe",
|
||||
@@ -219,6 +225,7 @@ export const apiEndpointKeys = {
|
||||
reboot: "aws.ec2.instances.reboot",
|
||||
terminate: "aws.ec2.instances.terminate",
|
||||
tags: "aws.ec2.instances.tags",
|
||||
securityGroups: "aws.ec2.instances.securityGroups",
|
||||
createImage: "aws.ec2.instances.create-image",
|
||||
console: "aws.ec2.instances.console",
|
||||
},
|
||||
@@ -547,6 +554,12 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
method: "DELETE",
|
||||
},
|
||||
],
|
||||
[apiEndpointKeys.clouds.resources.putKinesisRecord, {path: "/clouds/:cloud/services/kinesis/resources/:id/records", method: "POST"}],
|
||||
[apiEndpointKeys.clouds.resources.listKinesisRecords, {path: "/clouds/:cloud/services/kinesis/resources/:id/records", method: "GET"}],
|
||||
[apiEndpointKeys.clouds.resources.createLambdaEventSourceMapping, {path: "/clouds/:cloud/services/serverless/event-source-mappings", method: "POST"}],
|
||||
[apiEndpointKeys.clouds.resources.listLambdaEventSourceMappings, {path: "/clouds/:cloud/services/serverless/event-source-mappings", method: "GET"}],
|
||||
[apiEndpointKeys.clouds.resources.updateLambdaEventSourceMapping, {path: "/clouds/:cloud/services/serverless/event-source-mappings/:mappingId", method: "PATCH"}],
|
||||
[apiEndpointKeys.clouds.resources.deleteLambdaEventSourceMapping, {path: "/clouds/:cloud/services/serverless/event-source-mappings/:mappingId", method: "DELETE"}],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.startWorkflowExecution,
|
||||
{ path: "/clouds/:cloud/services/workflows/resources/:id/executions", method: "POST" },
|
||||
@@ -1113,6 +1126,7 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
[apiEndpointKeys.aws.ec2.instances.reboot, { path: "/ec2/instances/:instanceId/reboot", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.instances.terminate,{ path: "/ec2/instances/:instanceId/terminate", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.instances.tags, { path: "/ec2/instances/:instanceId/tags", method: "PUT" }],
|
||||
[apiEndpointKeys.aws.ec2.instances.securityGroups, { path: "/ec2/instances/:instanceId/security-groups", method: "PUT" }],
|
||||
[apiEndpointKeys.aws.ec2.instances.createImage, { path: "/ec2/instances/:instanceId/image", method: "POST" }],
|
||||
[apiEndpointKeys.aws.ec2.instances.console, { path: "/ec2/instances/:instanceId/console", method: "GET" }],
|
||||
[apiEndpointKeys.aws.ec2.amis.list, { path: "/ec2/amis", method: "GET" }],
|
||||
|
||||
@@ -75,6 +75,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 = {
|
||||
@@ -327,6 +335,14 @@ export async function updateEc2InstanceTags(
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateEc2InstanceSecurityGroups(instanceId: string, groupIds: string[]): Promise<void> {
|
||||
await apiClient.call(
|
||||
apiEndpointKeys.aws.ec2.instances.securityGroups,
|
||||
{ body: JSON.stringify({ groupIds }), headers: { "content-type": "application/json" } },
|
||||
{ instanceId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function createEc2Ami(instanceId: string, input: CreateAmiInput): Promise<{ imageId: string }> {
|
||||
const res = await apiClient.call<{ imageId: string }>(
|
||||
apiEndpointKeys.aws.ec2.instances.createImage,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||
import {deleteFlatItem, listFlatItems, putFlatItem} from './cloudProxyClient'
|
||||
|
||||
describe('generic flat-item requests for security-group ingress', () => {
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockImplementation(async (_url, init) => {
|
||||
const body = init?.method === 'GET'
|
||||
? {items: [], nextCursor: null}
|
||||
: {id: 'rule-id', collectionId: null, timestamp: null, body: {}, metadata: {}}
|
||||
return new Response(JSON.stringify(body), {status: 200, headers: {'Content-Type': 'application/json'}})
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
fetchMock.mockReset()
|
||||
})
|
||||
|
||||
it('lists, adds, and deletes through the networking child-item SPI', async () => {
|
||||
const permission = {protocol: 'tcp', fromPort: 443, toPort: 443, cidr: '0.0.0.0/0'}
|
||||
|
||||
await listFlatItems('aws', 'networking', 'sg-web')
|
||||
await putFlatItem('aws', 'networking', 'sg-web', permission)
|
||||
await deleteFlatItem('aws', 'networking', 'sg-web', 'rule/id')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
expect(String(fetchMock.mock.calls[0][0])).toContain('/api/clouds/aws/services/networking/resources/sg-web/items')
|
||||
expect(fetchMock.mock.calls[0][1]?.method).toBe('GET')
|
||||
expect(String(fetchMock.mock.calls[1][0])).toContain('/api/clouds/aws/services/networking/resources/sg-web/items')
|
||||
expect(fetchMock.mock.calls[1][1]?.method).toBe('POST')
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual(permission)
|
||||
expect(String(fetchMock.mock.calls[2][0])).toContain('/api/clouds/aws/services/networking/resources/sg-web/items/rule%2Fid')
|
||||
expect(fetchMock.mock.calls[2][1]?.method).toBe('DELETE')
|
||||
})
|
||||
})
|
||||
@@ -521,6 +521,39 @@ export interface SqsLambdaEventSourceMappingOverview {
|
||||
stateTransitionReason: string | null;
|
||||
}
|
||||
|
||||
export interface KinesisRecord {sequenceNumber: string; partitionKey: string | null; data: string; approximateArrivalTimestamp: string | null}
|
||||
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 interface CreateLambdaEventSourceMappingInput {functionName: string; eventSourceArn: string; batchSize?: number; startingPosition?: "TRIM_HORIZON" | "LATEST"}
|
||||
|
||||
export async function putKinesisRecord(cloud: CloudProvider, streamName: string, input: {partitionKey: string; data: string}): Promise<{sequenceNumber: string; shardId: string}> {
|
||||
const res = await apiClient.call<{sequenceNumber: string; shardId: string}, typeof input>(apiEndpointKeys.clouds.resources.putKinesisRecord, requestOptions(cloud, "kinesis", {body: input}), {cloud, id: streamName})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function listKinesisRecords(cloud: CloudProvider, streamName: string, signal?: AbortSignal): Promise<KinesisRecord[]> {
|
||||
const res = await apiClient.call<KinesisRecord[]>(apiEndpointKeys.clouds.resources.listKinesisRecords, requestOptions(cloud, "kinesis", {signal}), {cloud, id: streamName})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createLambdaEventSourceMapping(cloud: CloudProvider, input: CreateLambdaEventSourceMappingInput): Promise<LambdaEventSourceMappingOverview> {
|
||||
const res = await apiClient.call<LambdaEventSourceMappingOverview, CreateLambdaEventSourceMappingInput>(apiEndpointKeys.clouds.resources.createLambdaEventSourceMapping, requestOptions(cloud, "serverless", {body: input}), {cloud})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function listLambdaEventSourceMappings(cloud: CloudProvider, filter: {functionName?: string; eventSourceArn?: string}, signal?: AbortSignal): Promise<LambdaEventSourceMappingOverview[]> {
|
||||
const res = await apiClient.call<LambdaEventSourceMappingOverview[]>(apiEndpointKeys.clouds.resources.listLambdaEventSourceMappings, requestOptions(cloud, "serverless", {signal, params: filter}), {cloud})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateLambdaEventSourceMapping(cloud: CloudProvider, mappingId: string, input: {batchSize?: number; enabled?: boolean}): Promise<LambdaEventSourceMappingOverview> {
|
||||
const res = await apiClient.call<LambdaEventSourceMappingOverview, typeof input>(apiEndpointKeys.clouds.resources.updateLambdaEventSourceMapping, requestOptions(cloud, "serverless", {body: input}), {cloud, mappingId})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteLambdaEventSourceMapping(cloud: CloudProvider, mappingId: string): Promise<void> {
|
||||
await apiClient.call<void>(apiEndpointKeys.clouds.resources.deleteLambdaEventSourceMapping, requestOptions(cloud, "serverless"), {cloud, mappingId})
|
||||
}
|
||||
|
||||
export async function createApiGatewayLambdaRoute(
|
||||
cloud: CloudProvider,
|
||||
apiId: string,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import {apiClient} from "@/api/api";
|
||||
|
||||
export interface MskCluster { ClusterArn?: string; ClusterName?: string; State?: string; KafkaVersion?: string; NumberOfBrokerNodes?: number; }
|
||||
export interface CreateMskClusterInput { clusterName: string; kafkaVersion: string; numberOfBrokerNodes: number; brokerNodeGroupInfo: {clientSubnets: string[]}; }
|
||||
export interface MskTopic { name: string; partitionCount: number; }
|
||||
export interface MskMessage { partition: number; offset: string; timestamp: string; key?: string; value?: string; headers: Record<string, string>; }
|
||||
export interface MskConsumerGroupLag { topic: string; totalLag: number; partitions: Array<{partition: number; currentOffset: string; endOffset: string; lag: number}>; }
|
||||
|
||||
function clusterPath(clusterArn: string): string { return `/msk/clusters/${encodeURIComponent(clusterArn)}`; }
|
||||
|
||||
export async function listMskClusters(signal?: AbortSignal): Promise<MskCluster[]> { return (await apiClient.get<MskCluster[]>("/msk/clusters", {signal})).data; }
|
||||
export async function createMskCluster(input: CreateMskClusterInput): Promise<MskCluster> { return (await apiClient.post<MskCluster, CreateMskClusterInput>("/msk/clusters", input)).data; }
|
||||
export async function deleteMskCluster(clusterArn: string): Promise<void> { await apiClient.delete(clusterPath(clusterArn)); }
|
||||
export async function getMskBootstrapBrokers(clusterArn: string): Promise<string> { return (await apiClient.get<{bootstrapBrokers: string}>(`${clusterPath(clusterArn)}/bootstrap-brokers`)).data.bootstrapBrokers; }
|
||||
export async function listMskTopics(clusterArn: string, signal?: AbortSignal): Promise<MskTopic[]> { return (await apiClient.get<MskTopic[]>(`${clusterPath(clusterArn)}/topics`, {signal})).data; }
|
||||
export async function createMskTopic(clusterArn: string, input: {topic: string; numPartitions?: number; replicationFactor?: number; configEntries?: Array<{name: string; value: string}>}): Promise<void> { await apiClient.post(`${clusterPath(clusterArn)}/topics`, input); }
|
||||
export async function deleteMskTopic(clusterArn: string, topic: string): Promise<void> { await apiClient.delete(`${clusterPath(clusterArn)}/topics/${encodeURIComponent(topic)}`); }
|
||||
export async function produceMskMessage(clusterArn: string, topic: string, input: {key?: string; value: string; headers?: Record<string, string>}): Promise<Array<{topicName: string; partition: number; baseOffset?: string}>> { return (await apiClient.post<Array<{topicName: string; partition: number; baseOffset?: string}>, typeof input>(`${clusterPath(clusterArn)}/topics/${encodeURIComponent(topic)}/messages`, input)).data; }
|
||||
export async function peekMskMessages(clusterArn: string, topic: string, limit: number): Promise<MskMessage[]> { return (await apiClient.get<MskMessage[]>(`${clusterPath(clusterArn)}/topics/${encodeURIComponent(topic)}/messages`, {params: {limit}})).data; }
|
||||
export async function getMskConsumerGroupLag(clusterArn: string, groupId: string, topic?: string): Promise<MskConsumerGroupLag[]> { return (await apiClient.get<MskConsumerGroupLag[]>(`${clusterPath(clusterArn)}/consumer-groups/${encodeURIComponent(groupId)}/lag`, {params: {topic}})).data; }
|
||||
@@ -0,0 +1,128 @@
|
||||
import {apiClient} from "@/api/api";
|
||||
|
||||
export interface CustomPlugin {
|
||||
customPluginArn?: string;
|
||||
name?: string;
|
||||
revision?: number;
|
||||
customPluginState?: string;
|
||||
}
|
||||
|
||||
export interface ConnectorSummary {
|
||||
connectorArn?: string;
|
||||
connectorName?: string;
|
||||
connectorState?: string;
|
||||
}
|
||||
|
||||
export interface ConnectorDescription extends ConnectorSummary {
|
||||
currentVersion?: string;
|
||||
stateDescription?: string;
|
||||
connectorConfiguration?: Record<string, string>;
|
||||
capacity?: {provisionedCapacity?: {mcuCount?: number; workerCount?: number}};
|
||||
kafkaCluster?: {apacheKafkaCluster?: {bootstrapServers?: string}};
|
||||
kafkaConnectVersion?: string;
|
||||
plugins?: Array<{customPlugin?: {customPluginArn?: string; revision?: number}}>;
|
||||
logDelivery?: {workerLogDelivery?: {cloudWatchLogs?: {enabled?: boolean; logGroup?: string}}};
|
||||
}
|
||||
|
||||
export interface S3SinkConnectorInput {
|
||||
connectorName: string;
|
||||
connectorDescription?: string;
|
||||
bootstrapServers: string;
|
||||
kafkaConnectVersion: string;
|
||||
serviceExecutionRoleArn: string;
|
||||
customPluginArn: string;
|
||||
customPluginRevision: number;
|
||||
workerCount: number;
|
||||
mcuCount: number;
|
||||
topics: string;
|
||||
keyConverter: "string" | "json";
|
||||
valueConverter: "string" | "json";
|
||||
s3Bucket: string;
|
||||
s3Region: string;
|
||||
flushSize: string;
|
||||
rotateIntervalMs: string;
|
||||
logGroup?: string;
|
||||
}
|
||||
|
||||
export interface UpdateS3SinkConnectorInput extends Omit<S3SinkConnectorInput, "connectorName" | "connectorDescription" | "bootstrapServers" | "kafkaConnectVersion" | "serviceExecutionRoleArn" | "customPluginArn" | "customPluginRevision" | "logGroup"> {
|
||||
currentVersion: string;
|
||||
}
|
||||
|
||||
const basePath = "/msk-connect";
|
||||
|
||||
export async function listCustomPlugins(signal?: AbortSignal): Promise<CustomPlugin[]> {
|
||||
return (await apiClient.get<CustomPlugin[]>(`${basePath}/custom-plugins`, {signal})).data;
|
||||
}
|
||||
|
||||
export async function registerCustomPlugin(input: {name: string; bucket: string; fileKey: string; objectVersion?: string; description?: string}): Promise<CustomPlugin> {
|
||||
return (await apiClient.post<CustomPlugin, object>(`${basePath}/custom-plugins`, {
|
||||
name: input.name,
|
||||
contentType: "JAR",
|
||||
description: input.description || undefined,
|
||||
location: {s3Location: {bucketArn: `arn:aws:s3:::${input.bucket}`, fileKey: input.fileKey, objectVersion: input.objectVersion || undefined}},
|
||||
})).data;
|
||||
}
|
||||
|
||||
export async function deleteCustomPlugin(customPluginArn: string): Promise<void> {
|
||||
await apiClient.delete(`${basePath}/custom-plugins/${encodeURIComponent(customPluginArn)}`);
|
||||
}
|
||||
|
||||
export async function listConnectors(signal?: AbortSignal): Promise<ConnectorSummary[]> {
|
||||
return (await apiClient.get<ConnectorSummary[]>(`${basePath}/connectors`, {signal})).data;
|
||||
}
|
||||
|
||||
export async function describeConnector(connectorArn: string, signal?: AbortSignal): Promise<ConnectorDescription> {
|
||||
return (await apiClient.get<ConnectorDescription>(`${basePath}/connectors/${encodeURIComponent(connectorArn)}`, {signal})).data;
|
||||
}
|
||||
|
||||
export async function createS3SinkConnector(input: S3SinkConnectorInput): Promise<ConnectorSummary> {
|
||||
return (await apiClient.post<ConnectorSummary, object>(`${basePath}/connectors`, connectorRequest(input), {timeout: 60_000})).data;
|
||||
}
|
||||
|
||||
export async function updateS3SinkConnector(connectorArn: string, input: UpdateS3SinkConnectorInput): Promise<ConnectorSummary> {
|
||||
return (await apiClient.patch<ConnectorSummary, object>(`${basePath}/connectors/${encodeURIComponent(connectorArn)}`, {
|
||||
currentVersion: input.currentVersion,
|
||||
capacity: {provisionedCapacity: {workerCount: input.workerCount, mcuCount: input.mcuCount}},
|
||||
connectorConfiguration: sinkConfiguration(input),
|
||||
})).data;
|
||||
}
|
||||
|
||||
export async function deleteConnector(connectorArn: string): Promise<void> {
|
||||
await apiClient.delete(`${basePath}/connectors/${encodeURIComponent(connectorArn)}`);
|
||||
}
|
||||
|
||||
function connectorRequest(input: S3SinkConnectorInput): object {
|
||||
return {
|
||||
connectorName: input.connectorName,
|
||||
connectorDescription: input.connectorDescription || undefined,
|
||||
capacity: {provisionedCapacity: {workerCount: input.workerCount, mcuCount: input.mcuCount}},
|
||||
connectorConfiguration: sinkConfiguration(input),
|
||||
kafkaCluster: {apacheKafkaCluster: {bootstrapServers: input.bootstrapServers}},
|
||||
kafkaClusterClientAuthentication: {authenticationType: "NONE"},
|
||||
kafkaClusterEncryptionInTransit: {encryptionType: "PLAINTEXT"},
|
||||
kafkaConnectVersion: input.kafkaConnectVersion,
|
||||
plugins: [{customPlugin: {customPluginArn: input.customPluginArn, revision: input.customPluginRevision}}],
|
||||
serviceExecutionRoleArn: input.serviceExecutionRoleArn,
|
||||
logDelivery: input.logGroup ? {workerLogDelivery: {cloudWatchLogs: {enabled: true, logGroup: input.logGroup}}} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function sinkConfiguration(input: Pick<S3SinkConnectorInput, "topics" | "keyConverter" | "valueConverter" | "s3Bucket" | "s3Region" | "flushSize" | "rotateIntervalMs">): Record<string, string> {
|
||||
return {
|
||||
"connector.class": "io.confluent.connect.s3.S3SinkConnector",
|
||||
"tasks.max": "1",
|
||||
topics: input.topics,
|
||||
"key.converter": converterClass(input.keyConverter),
|
||||
"value.converter": converterClass(input.valueConverter),
|
||||
"key.converter.schemas.enable": "false",
|
||||
"value.converter.schemas.enable": "false",
|
||||
"s3.bucket.name": input.s3Bucket,
|
||||
"s3.region": input.s3Region,
|
||||
"flush.size": input.flushSize,
|
||||
"rotate.interval.ms": input.rotateIntervalMs,
|
||||
};
|
||||
}
|
||||
|
||||
function converterClass(value: "string" | "json"): string {
|
||||
return value === "json" ? "org.apache.kafka.connect.json.JsonConverter" : "org.apache.kafka.connect.storage.StringConverter";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {apiClient} from '@/api/api'
|
||||
|
||||
export interface SesIdentity {IdentityName: string; IdentityType?: string; VerifiedForSendingStatus?: boolean}
|
||||
export interface SesTemplate {TemplateName: string; CreatedTimestamp?: string}
|
||||
export interface SesConfigurationSet {ConfigurationSetName: string; LastFreshStart?: string}
|
||||
export interface SesEventDestination {EventDestinationName?: string; Enabled?: boolean; MatchingEventTypes?: string[]; SnsDestination?: {TopicArn?: string}; EventBridgeDestination?: {EventBusArn?: string}; CloudWatchDestination?: {DimensionConfigurations?: Array<{DimensionName: string; DimensionValueSource: string; DefaultDimensionValue?: string}>}}
|
||||
export interface SesTopic {TopicArn?: string}
|
||||
export interface SesEventBus {Name?: string; Arn?: string}
|
||||
|
||||
export async function listSesIdentities(): Promise<SesIdentity[]> { return (await apiClient.get<SesIdentity[]>('/ses/identities')).data }
|
||||
export async function createSesIdentity(emailIdentity: string): Promise<void> { await apiClient.post('/ses/identities', {EmailIdentity: emailIdentity}) }
|
||||
export async function deleteSesIdentity(identity: string): Promise<void> { await apiClient.delete(`/ses/identities/${encodeURIComponent(identity)}`) }
|
||||
export async function sendSesSimple(input: unknown): Promise<void> { await apiClient.post('/ses/emails/simple', input) }
|
||||
export async function sendSesRaw(input: unknown): Promise<void> { await apiClient.post('/ses/emails/raw', input) }
|
||||
export async function sendSesTemplate(input: unknown): Promise<void> { await apiClient.post('/ses/emails/template', input) }
|
||||
export async function listSesSuppressions(): Promise<Array<{EmailAddress?: string; Reason?: string; LastUpdateTime?: string}>> { return (await apiClient.get<Array<{EmailAddress?: string; Reason?: string; LastUpdateTime?: string}>>('/ses/suppressions')).data }
|
||||
export async function putSesSuppression(emailAddress: string, reason: 'BOUNCE' | 'COMPLAINT'): Promise<void> { await apiClient.post('/ses/suppressions', {EmailAddress: emailAddress, Reason: reason}) }
|
||||
export async function deleteSesSuppression(emailAddress: string): Promise<void> { await apiClient.delete(`/ses/suppressions/${encodeURIComponent(emailAddress)}`) }
|
||||
export async function listSesTemplates(): Promise<SesTemplate[]> { return (await apiClient.get<SesTemplate[]>('/ses/templates')).data }
|
||||
export async function getSesTemplate(templateName: string): Promise<{TemplateContent?: {Subject?: string; Html?: string; Text?: string}}> { return (await apiClient.get<{TemplateContent?: {Subject?: string; Html?: string; Text?: string}}>(`/ses/templates/${encodeURIComponent(templateName)}`)).data }
|
||||
export async function saveSesTemplate(templateName: string, templateContent: {Subject: string; Html?: string; Text?: string}, existing: boolean): Promise<void> { if (existing) await apiClient.put(`/ses/templates/${encodeURIComponent(templateName)}`, {TemplateContent: templateContent}); else await apiClient.post('/ses/templates', {TemplateName: templateName, TemplateContent: templateContent}) }
|
||||
export async function deleteSesTemplate(templateName: string): Promise<void> { await apiClient.delete(`/ses/templates/${encodeURIComponent(templateName)}`) }
|
||||
export async function renderSesTemplate(templateName: string, templateData: string): Promise<string> { return (await apiClient.post<{RenderedTemplate: string}, {TemplateName: string; TemplateData: string}>('/ses/templates/test-render', {TemplateName: templateName, TemplateData: templateData})).data.RenderedTemplate }
|
||||
export async function listSesConfigurationSets(): Promise<SesConfigurationSet[]> { return (await apiClient.get<SesConfigurationSet[]>('/ses/configuration-sets')).data }
|
||||
export async function createSesConfigurationSet(configurationSetName: string): Promise<void> { await apiClient.post('/ses/configuration-sets', {ConfigurationSetName: configurationSetName}) }
|
||||
export async function deleteSesConfigurationSet(name: string): Promise<void> { await apiClient.delete(`/ses/configuration-sets/${encodeURIComponent(name)}`) }
|
||||
export async function listSesEventDestinations(name: string): Promise<SesEventDestination[]> { return (await apiClient.get<SesEventDestination[]>(`/ses/configuration-sets/${encodeURIComponent(name)}/event-destinations`)).data }
|
||||
export async function createSesEventDestination(name: string, input: unknown): Promise<void> { await apiClient.post(`/ses/configuration-sets/${encodeURIComponent(name)}/event-destinations`, input) }
|
||||
export async function deleteSesEventDestination(name: string, destination: string): Promise<void> { await apiClient.delete(`/ses/configuration-sets/${encodeURIComponent(name)}/event-destinations/${encodeURIComponent(destination)}`) }
|
||||
export async function listSesTopics(): Promise<SesTopic[]> { return (await apiClient.get<SesTopic[]>('/ses/inventory/sns-topics')).data }
|
||||
export async function listSesEventBuses(): Promise<SesEventBus[]> { return (await apiClient.get<SesEventBus[]>('/ses/inventory/event-buses')).data }
|
||||
@@ -141,7 +141,7 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
<h3>Select a REST API</h3>
|
||||
<p>Select an API Gateway REST API to connect a route to a Lambda function.</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
const baseInvokeUrl = endpoint ? `${endpoint}/execute-api/${resource.id}/${stage}` : null
|
||||
|
||||
return (
|
||||
<div className="apigateway-workspace">
|
||||
<section className="table-panel">
|
||||
<div className="dynamic-stage-header">
|
||||
<div>
|
||||
@@ -165,14 +166,6 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
<h3><Link2 size={15}/> Connect Lambda</h3>
|
||||
<p className="muted compact-text">Creates and deploys one Lambda proxy route to the selected REST API.</p>
|
||||
</div>
|
||||
{(testMutation.data || methodTestMutation.data) && functionName && (
|
||||
<LogsQueryPanel
|
||||
cloud={cloud}
|
||||
logGroupName={`/aws/lambda/${functionName}`}
|
||||
runtimeReachable={runtimeReachable}
|
||||
autoRunKey={logsRefreshKey}
|
||||
/>
|
||||
)}
|
||||
{functionName && <a className="button" href={`/cloud-explorer/aws/logs?resource=${encodeURIComponent(`/aws/lambda/${functionName}`)}`}><ScrollText size={13}/> Open Lambda logs</a>}
|
||||
</div>
|
||||
<div className="resource-create-inline">
|
||||
@@ -298,9 +291,10 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{corsProxyMethods.length > 0 && <ProxyCorsWarning methods={corsProxyMethods} origin={corsOrigin} headers={corsHeaders}/>}
|
||||
</div>
|
||||
<div className="apigateway-test-panel">
|
||||
{corsProxyMethods.length > 0 && <ProxyCorsWarning methods={corsProxyMethods} origin={corsOrigin} headers={corsHeaders}/>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="table-panel apigateway-test-panel">
|
||||
<div className="dynamic-stage-header">
|
||||
<div>
|
||||
<p className="eyebrow">Method Test</p>
|
||||
@@ -442,8 +436,14 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
) : <p className="muted compact-text">Send a request to inspect the API response.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(testMutation.data || methodTestMutation.data) && functionName && <LogsQueryPanel
|
||||
cloud={cloud}
|
||||
logGroupName={`/aws/lambda/${functionName}`}
|
||||
runtimeReachable={runtimeReachable}
|
||||
autoRunKey={logsRefreshKey}
|
||||
/>}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {expect, test, vi} from 'vitest'
|
||||
import {parse} from 'yaml'
|
||||
import {CodeBuildPanel, DEFAULT_BUILDSPEC} from './CodeBuildPanel'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => ({
|
||||
getCodeBuild: vi.fn(),
|
||||
listCodeBuilds: vi.fn().mockResolvedValue([]),
|
||||
startCodeBuild: vi.fn(),
|
||||
}))
|
||||
|
||||
const project: CloudResource = {id: 'orders', name: 'orders', cloud: 'aws', service: 'codebuild', type: 'codebuild-project', region: 'us-east-1', createdAt: null, metadata: {sourceType: 'NO_SOURCE', ecrRepository: 'orders'}}
|
||||
|
||||
test('shows the corrected CodeDeployToECS artifact guidance and default buildspec', async () => {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
const user = userEvent.setup()
|
||||
render(<QueryClientProvider client={client}><CodeBuildPanel resource={project} runtimeReachable/></QueryClientProvider>)
|
||||
|
||||
expect(screen.getByText('{"ImageURI":"..."}')).toBeInTheDocument()
|
||||
expect(screen.getByText('taskdef.json')).toBeInTheDocument()
|
||||
expect(screen.getByText('appspec.yaml')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', {name: 'Start build'}))
|
||||
expect(screen.getByRole('textbox', {name: 'Buildspec override'})).toHaveValue(DEFAULT_BUILDSPEC)
|
||||
expect(parse(DEFAULT_BUILDSPEC).artifacts.files).toEqual(['imageDetail.json', 'taskdef.json', 'appspec.yaml'])
|
||||
})
|
||||
@@ -18,7 +18,7 @@ export function CodeBuildPanel({resource, runtimeReachable}: CodeBuildPanelProps
|
||||
const buildsKey = ['codebuild-builds', resource?.id]
|
||||
const builds = useQuery({queryKey: buildsKey, queryFn: ({signal}) => listCodeBuilds(resource!.id, signal), enabled, refetchInterval: (query) => query.state.data?.some((build) => active(build.buildStatus)) ? 2_000 : false})
|
||||
const detail = useQuery({queryKey: ['codebuild-build', resource?.id, selectedId], queryFn: ({signal}) => getCodeBuild(resource!.id, selectedId!, signal), enabled: enabled && Boolean(selectedId), refetchInterval: (query) => active(query.state.data?.buildStatus) ? 2_000 : false})
|
||||
const start = useMutation({mutationFn: () => startCodeBuild(resource!.id, {environmentVariablesOverride: overrides.length ? overrides : undefined, buildspecOverride: buildspecOverride.trim() || undefined}), onSuccess: (build) => { setSelectedId(build.id); setStartOpen(false); setOverrides([]); setBuildspecOverride(DEFAULT_BUILDSPEC); void client.invalidateQueries({queryKey: buildsKey}) }})
|
||||
const start = useMutation({mutationFn: () => startCodeBuild(resource!.id, {environmentVariablesOverride: overrides.length ? overrides : undefined, buildspecOverride: buildspecOverride.trim() || undefined}), onSuccess: (build) => { setSelectedId(build.id); setStartOpen(false); setOverrides([]); setBuildspecOverride(defaultBuildspec(projectSourceType)); void client.invalidateQueries({queryKey: buildsKey}) }})
|
||||
useEffect(() => { setSelectedId(undefined); setStartOpen(false); setOverrides([]); setBuildspecOverride(defaultBuildspec(projectSourceType)) }, [resource?.id, projectSourceType])
|
||||
if (!resource) return null
|
||||
const ecrRepository = typeof resource.metadata.ecrRepository === 'string' ? resource.metadata.ecrRepository : undefined
|
||||
@@ -28,7 +28,7 @@ export function CodeBuildPanel({resource, runtimeReachable}: CodeBuildPanelProps
|
||||
{startOpen && (
|
||||
<StartBuildDialog overrides={overrides} buildspecOverride={buildspecOverride} requiresBuildspec={projectSourceType === 'NO_SOURCE'} pending={start.isPending} error={start.error} onChange={setOverrides} onBuildspecChange={setBuildspecOverride} onCancel={() => setStartOpen(false)} onStart={() => start.mutate()}/>
|
||||
)}
|
||||
{ecrRepository && <p className="muted compact-text">Docker builds push to ECR repository <a href={`/cloud-explorer/aws/ecr?resource=${encodeURIComponent(ecrRepository)}`}>{ecrRepository}</a> and publish `imageDetail.json`, `taskdef.json`, and `appspec.yaml` for CodeDeployToECS.</p>}
|
||||
{ecrRepository && <p className="muted compact-text">Docker builds push to local ECR repository <a href={`/cloud-explorer/aws/ecr?resource=${encodeURIComponent(ecrRepository)}`}>{ecrRepository}</a>. For CodeDeployToECS, BuildOutput must include <code>imageDetail.json</code> as <code>{'{"ImageURI":"..."}'}</code>, plus <code>taskdef.json</code> and <code>appspec.yaml</code>.</p>}
|
||||
{builds.isError && <p className="error-text compact-text">{message(builds.error, 'Unable to load builds.')}</p>}
|
||||
<div className="workflow-execution-list"><div className="workflow-execution-list-heading"><h4>Build history</h4><span>{builds.data?.length ?? 0}</span></div><div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Build</th><th>Status</th><th>Phase</th><th>Started</th></tr></thead><tbody>{(builds.data ?? []).map((build) => <tr key={build.id} onClick={() => setSelectedId(build.id)} style={{cursor: 'pointer'}}><td><code>#{build.buildNumber ?? build.id}</code></td><td>{build.buildStatus ?? '-'}</td><td>{build.currentPhase ?? '-'}</td><td>{formatDateTime(build.startTime) ?? '-'}</td></tr>)}</tbody></table></div>{!builds.isLoading && builds.data?.length === 0 && <p className="muted compact-text">No builds yet.</p>}</div>
|
||||
{selectedId && <BuildDetail build={selected} loading={detail.isLoading} error={detail.error}/>}
|
||||
@@ -48,7 +48,7 @@ function BuildDetail({build, loading, error}: {build?: CodeBuildBuild; loading:
|
||||
}
|
||||
|
||||
function replace<T>(items: T[], index: number, value: T): T[] { return items.map((item, itemIndex) => itemIndex === index ? value : item) }
|
||||
const DEFAULT_BUILDSPEC = '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'
|
||||
export const DEFAULT_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'
|
||||
function sourceType(resource?: CloudResource): string | undefined { return typeof resource?.metadata.sourceType === 'string' ? resource.metadata.sourceType : undefined }
|
||||
function defaultBuildspec(sourceType?: string): string { return sourceType === 'S3' ? '' : DEFAULT_BUILDSPEC }
|
||||
function active(status?: string): boolean { return status === 'IN_PROGRESS' }
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {describe, expect, test, vi} from 'vitest'
|
||||
import {CodeDeployCreatePanel} from './CodeDeployPanel'
|
||||
|
||||
describe('CodeDeployCreatePanel', () => {
|
||||
test('preserves the Lambda creation flow by default', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSubmit = vi.fn()
|
||||
render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
|
||||
|
||||
await user.type(screen.getByLabelText('Application name'), 'orders')
|
||||
await user.type(screen.getByLabelText('Deployment group name'), 'production')
|
||||
await user.type(screen.getByLabelText('CodeDeploy service role ARN'), 'role-arn')
|
||||
await user.click(screen.getByRole('button', {name: 'Create deployment group'}))
|
||||
|
||||
expect(screen.queryByLabelText('ECS cluster name')).not.toBeInTheDocument()
|
||||
expect(onSubmit).toHaveBeenCalledWith({applicationName: 'orders', deploymentGroupName: 'production', computePlatform: 'Lambda', serviceRoleArn: 'role-arn', deploymentConfig: 'AllAtOnce', rollbackOnFailure: true})
|
||||
})
|
||||
|
||||
test('submits ECS blue-green deployment group inputs', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSubmit = vi.fn()
|
||||
render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
|
||||
|
||||
await user.selectOptions(screen.getByLabelText('Compute platform'), 'ECS')
|
||||
await user.type(screen.getByLabelText('Application name'), 'orders')
|
||||
await user.type(screen.getByLabelText('Deployment group name'), 'production')
|
||||
await user.type(screen.getByLabelText('CodeDeploy service role ARN'), 'role-arn')
|
||||
await user.type(screen.getByLabelText('ECS cluster name'), 'orders-cluster')
|
||||
await user.type(screen.getByLabelText('ECS service name'), 'orders-service')
|
||||
await user.type(screen.getByLabelText('Blue target group name'), 'orders-blue')
|
||||
await user.type(screen.getByLabelText('Green target group name'), 'orders-green')
|
||||
await user.type(screen.getByLabelText('Production listener ARN'), 'listener-arn')
|
||||
await user.selectOptions(screen.getByLabelText('Traffic configuration'), 'Linear10PercentEvery3Minutes')
|
||||
await user.click(screen.getByLabelText('Roll back automatically on deployment failure'))
|
||||
await user.click(screen.getByRole('button', {name: 'Create deployment group'}))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({applicationName: 'orders', deploymentGroupName: 'production', computePlatform: 'ECS', serviceRoleArn: 'role-arn', clusterName: 'orders-cluster', serviceName: 'orders-service', blueTargetGroupName: 'orders-blue', greenTargetGroupName: 'orders-green', productionListenerArn: 'listener-arn', deploymentConfig: 'Linear10PercentEvery3Minutes', rollbackOnFailure: false})
|
||||
})
|
||||
})
|
||||
@@ -8,17 +8,32 @@ import type {CloudResource} from '@/types/resource'
|
||||
interface CreateProps { isSubmitting: boolean; submitError: string | null; onCancel: () => void; onSubmit: (values: Record<string, unknown>) => void }
|
||||
|
||||
export function CodeDeployCreatePanel({isSubmitting, submitError, onCancel, onSubmit}: CreateProps) {
|
||||
const [computePlatform, setComputePlatform] = useState<'Lambda' | 'ECS'>('Lambda')
|
||||
const [applicationName, setApplicationName] = useState('')
|
||||
const [deploymentGroupName, setDeploymentGroupName] = useState('')
|
||||
const [serviceRoleArn, setServiceRoleArn] = useState('')
|
||||
const [clusterName, setClusterName] = useState('')
|
||||
const [serviceName, setServiceName] = useState('')
|
||||
const [blueTargetGroupName, setBlueTargetGroupName] = useState('')
|
||||
const [greenTargetGroupName, setGreenTargetGroupName] = useState('')
|
||||
const [productionListenerArn, setProductionListenerArn] = useState('')
|
||||
const [deploymentConfig, setDeploymentConfig] = useState('AllAtOnce')
|
||||
const [rollbackOnFailure, setRollbackOnFailure] = useState(true)
|
||||
return <form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); onSubmit({applicationName, deploymentGroupName, serviceRoleArn, deploymentConfig, rollbackOnFailure}) }}>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Lambda deployment</p><h3>Create application and deployment group</h3><p className="muted compact-text">The group controls the traffic-shift policy and rollback on deployment failure.</p></div></div>
|
||||
const ecs = computePlatform === 'ECS'
|
||||
return <form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); onSubmit({applicationName, deploymentGroupName, computePlatform, serviceRoleArn, ...(ecs ? {clusterName, serviceName, blueTargetGroupName, greenTargetGroupName, productionListenerArn} : {}), deploymentConfig, rollbackOnFailure}) }}>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">{computePlatform} deployment</p><h3>Create application and deployment group</h3><p className="muted compact-text">The group controls the traffic-shift policy and rollback on deployment failure.</p></div></div>
|
||||
<label>Compute platform<select className="input" value={computePlatform} onChange={(event) => { setComputePlatform(event.target.value as 'Lambda' | 'ECS'); setDeploymentConfig('AllAtOnce') }}><option value="Lambda">Lambda</option><option value="ECS">ECS blue/green</option></select></label>
|
||||
<label>Application name<input className="input" required value={applicationName} onChange={(event) => setApplicationName(event.target.value)}/></label>
|
||||
<label>Deployment group name<input className="input" required value={deploymentGroupName} onChange={(event) => setDeploymentGroupName(event.target.value)}/></label>
|
||||
<label>CodeDeploy service role ARN<input className="input" required value={serviceRoleArn} onChange={(event) => setServiceRoleArn(event.target.value)} placeholder="arn:aws:iam::000000000000:role/CodeDeployRole"/></label>
|
||||
<label>Traffic configuration<select className="input" value={deploymentConfig} onChange={(event) => setDeploymentConfig(event.target.value)}><option value="AllAtOnce">All at once</option><option value="Canary10Percent5Minutes">Canary 10% then 5 minutes</option><option value="Canary10Percent30Minutes">Canary 10% then 30 minutes</option><option value="Linear10PercentEvery1Minute">Linear 10% every minute</option><option value="Linear10PercentEvery10Minutes">Linear 10% every 10 minutes</option></select></label>
|
||||
{ecs && <>
|
||||
<label>ECS cluster name<input className="input" required value={clusterName} onChange={(event) => setClusterName(event.target.value)}/></label>
|
||||
<label>ECS service name<input className="input" required value={serviceName} onChange={(event) => setServiceName(event.target.value)}/></label>
|
||||
<label>Blue target group name<input className="input" required value={blueTargetGroupName} onChange={(event) => setBlueTargetGroupName(event.target.value)}/></label>
|
||||
<label>Green target group name<input className="input" required value={greenTargetGroupName} onChange={(event) => setGreenTargetGroupName(event.target.value)}/></label>
|
||||
<label>Production listener ARN<input className="input" required value={productionListenerArn} onChange={(event) => setProductionListenerArn(event.target.value)} placeholder="arn:aws:elasticloadbalancing:us-east-1:000000000000:listener/app/..."/></label>
|
||||
</>}
|
||||
<label>Traffic configuration<select className="input" value={deploymentConfig} onChange={(event) => setDeploymentConfig(event.target.value)}><option value="AllAtOnce">All at once</option>{ecs ? <><option value="Canary10Percent5Minutes">Canary 10% then 5 minutes</option><option value="Canary10Percent15Minutes">Canary 10% then 15 minutes</option><option value="Linear10PercentEvery1Minutes">Linear 10% every minute</option><option value="Linear10PercentEvery3Minutes">Linear 10% every 3 minutes</option></> : <><option value="Canary10Percent5Minutes">Canary 10% then 5 minutes</option><option value="Canary10Percent30Minutes">Canary 10% then 30 minutes</option><option value="Linear10PercentEvery1Minute">Linear 10% every minute</option><option value="Linear10PercentEvery10Minutes">Linear 10% every 10 minutes</option></>}</select></label>
|
||||
<label className="checkbox-row"><input type="checkbox" checked={rollbackOnFailure} onChange={(event) => setRollbackOnFailure(event.target.checked)}/> Roll back automatically on deployment failure</label>
|
||||
{submitError && <p className="error-text compact-text">{submitError}</p>}
|
||||
<div className="form-actions"><button className="button" type="button" disabled={isSubmitting} onClick={onCancel}>Cancel</button><button className="button primary" type="submit" disabled={isSubmitting}>{isSubmitting ? 'Creating' : 'Create deployment group'}</button></div>
|
||||
@@ -43,7 +58,7 @@ export function CodeDeployPanel({resource, runtimeReachable}: {resource?: CloudR
|
||||
if (!resource) return null
|
||||
const ecsServices = Array.isArray(resource.metadata.ecsServices) ? resource.metadata.ecsServices as Array<{clusterName?: string; serviceName?: string}> : []
|
||||
return <section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">{ecsServices.length ? 'ECS EC2 release' : 'Lambda release'}</p><h3>{ecsServices.length ? 'CodeDeploy deployment status' : 'Deploy a version to an alias'}</h3><p className="muted compact-text">{String(resource.metadata.applicationName)} / {resource.name}{ecsServices.length ? ` | ${ecsServices.map((service) => `${service.clusterName}/${service.serviceName}`).join(', ')}` : ''}</p></div><button className="button" type="button" disabled={!enabled || deployments.isFetching} onClick={() => void deployments.refetch()}><RefreshCw size={13}/>Refresh</button></div>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">{ecsServices.length ? 'ECS release' : 'Lambda release'}</p><h3>{ecsServices.length ? 'CodeDeploy deployment status' : 'Deploy a version to an alias'}</h3><p className="muted compact-text">{String(resource.metadata.applicationName)} / {resource.name}{ecsServices.length ? ` | ${ecsServices.map((service) => `${service.clusterName}/${service.serviceName}`).join(', ')}` : ''}</p></div><button className="button" type="button" disabled={!enabled || deployments.isFetching} onClick={() => void deployments.refetch()}><RefreshCw size={13}/>Refresh</button></div>
|
||||
{!ecsServices.length && <form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); start.mutate() }}>
|
||||
<label>Function<select className="input" required value={functionName} onChange={(event) => { setFunctionName(event.target.value); setAlias(''); setCurrentVersion(''); setTargetVersion('') }}><option value="">Select Lambda function</option>{(dependencies.data?.lambdaFunctions ?? []).map((item) => <option key={item.name} value={item.name}>{item.name}</option>)}</select></label>
|
||||
<label>Alias<select className="input" required value={alias} onChange={(event) => setAlias(event.target.value)} disabled={!selected}><option value="">Select alias</option>{selected?.aliases.map((item) => <option key={item} value={item}>{item}</option>)}</select></label>
|
||||
|
||||
@@ -15,7 +15,7 @@ export function CodePipelinePanel({resource, runtimeReachable}: CodePipelinePane
|
||||
const start = useMutation({mutationFn: () => startCodePipeline(resource!.id), onSuccess: () => { void client.invalidateQueries({queryKey: executionsKey}); void client.invalidateQueries({queryKey: ['codepipeline-state', resource?.id]}) }})
|
||||
if (!resource) return null
|
||||
return <section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Pipeline execution</p><h3>Run and inspect stages</h3><p className="muted compact-text">Local actions: S3 source, CodeBuild, and CodeDeployToECS for ECS EC2 services.</p></div><div className="header-actions"><button className="button" type="button" disabled={!enabled || executions.isFetching || state.isFetching} onClick={() => { void executions.refetch(); void state.refetch() }}><RefreshCw size={13}/>Refresh</button><button className="button primary" type="button" disabled={!enabled || start.isPending} onClick={() => start.mutate()}><Play size={13}/>{start.isPending ? 'Starting' : 'Release change'}</button></div></div>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Pipeline execution</p><h3>Run and inspect stages</h3><p className="muted compact-text">Local actions: S3 source, CodeBuild, and CodeDeployToECS for ECS EC2 and Fargate services.</p></div><div className="header-actions"><button className="button" type="button" disabled={!enabled || executions.isFetching || state.isFetching} onClick={() => { void executions.refetch(); void state.refetch() }}><RefreshCw size={13}/>Refresh</button><button className="button primary" type="button" disabled={!enabled || start.isPending} onClick={() => start.mutate()}><Play size={13}/>{start.isPending ? 'Starting' : 'Release change'}</button></div></div>
|
||||
{start.isError && <p className="error-text compact-text">{message(start.error, 'Unable to start pipeline execution.')}</p>}
|
||||
{(executions.isError || state.isError) && <p className="error-text compact-text">{message(executions.error ?? state.error, 'Unable to load pipeline execution state.')}</p>}
|
||||
<div className="workflow-execution-list"><div className="workflow-execution-list-heading"><h4>Execution history</h4><span>{executions.data?.length ?? 0}</span></div><div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Execution</th><th>Status</th><th>Started</th><th>Updated</th></tr></thead><tbody>{(executions.data ?? []).map((execution) => <tr key={execution.id}><td><code>{execution.id}</code>{execution.statusSummary && <p className="muted compact-text">{execution.statusSummary}</p>}</td><td>{execution.status ?? '-'}</td><td>{formatDateTime(execution.startTime) ?? '-'}</td><td>{formatDateTime(execution.lastUpdateTime) ?? '-'}</td></tr>)}</tbody></table></div>{!executions.isLoading && executions.data?.length === 0 && <p className="muted compact-text">No executions yet.</p>}</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { CopyValueButton } from './CopyValueButton'
|
||||
import {
|
||||
AlertTriangle,
|
||||
HardDrive,
|
||||
@@ -32,10 +33,12 @@ import {
|
||||
startEc2Instance,
|
||||
stopEc2Instance,
|
||||
terminateEc2Instance,
|
||||
updateEc2InstanceSecurityGroups,
|
||||
updateEc2InstanceTags,
|
||||
type Ec2Image,
|
||||
type Ec2KeyPair,
|
||||
type Ec2SecurityGroup,
|
||||
type Ec2SecurityGroupRef,
|
||||
type Ec2Subnet,
|
||||
type Ec2Tag,
|
||||
type Ec2Vpc,
|
||||
@@ -143,6 +146,42 @@ function InlineTagEditor({ instanceId, initialTags }: { instanceId: string; init
|
||||
)
|
||||
}
|
||||
|
||||
function InlineSecurityGroupEditor({ instanceId, vpcId, initialGroups }: {
|
||||
instanceId: string; vpcId?: string; initialGroups: Ec2SecurityGroupRef[]
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [selected, setSelected] = useState(() => new Set(initialGroups.map((group) => group.id).filter(Boolean) as string[]))
|
||||
const groups = useQuery({queryKey: ['ec2', 'security-groups', vpcId], queryFn: ({signal}) => listEc2SecurityGroups(vpcId, signal)})
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => updateEc2InstanceSecurityGroups(instanceId, [...selected]),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({queryKey: ['ec2', 'instance', instanceId]})
|
||||
void qc.invalidateQueries({queryKey: ['ec2', 'instances']})
|
||||
void qc.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'compute']})
|
||||
},
|
||||
})
|
||||
const compatibleGroups = (groups.data ?? []).filter((group) => !vpcId || group.vpcId === vpcId)
|
||||
|
||||
return <div style={{display: 'flex', flexDirection: 'column', gap: 8}}>
|
||||
{compatibleGroups.map((group) => <label key={group.groupId} style={{display: 'flex', gap: 6, alignItems: 'center'}}>
|
||||
<input type="checkbox" checked={selected.has(group.groupId)} onChange={(event) => {
|
||||
setSelected((previous) => {
|
||||
const next = new Set(previous)
|
||||
if (event.target.checked) next.add(group.groupId)
|
||||
else next.delete(group.groupId)
|
||||
return next
|
||||
})
|
||||
}}/>
|
||||
<span>{group.groupName} <code>{group.groupId}</code></span>
|
||||
</label>)}
|
||||
{groups.isError && <p className="error-text compact-text">Unable to load security groups.</p>}
|
||||
{mutation.isError && <p className="error-text compact-text">{mutation.error instanceof Error ? mutation.error.message : 'Save failed'}</p>}
|
||||
<button className="button primary compact" disabled={groups.isLoading || selected.size === 0 || mutation.isPending} onClick={() => mutation.mutate()}>
|
||||
<Save size={13}/>{mutation.isPending ? 'Saving' : 'Save security groups'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
// ─── Create AMI modal (inline) ────────────────────────────────────────────────
|
||||
|
||||
function CreateAmiModal({ instanceId, instanceName, onClose, onCreated }: {
|
||||
@@ -341,7 +380,7 @@ function DeregisterAmiConfirm({ imageId, onConfirm, onCancel, isPending }: {
|
||||
|
||||
function InstancePanel({ instanceId }: { instanceId: string }) {
|
||||
const qc = useQueryClient()
|
||||
const [tab, setTab] = useState<'details' | 'tags' | 'console'>('details')
|
||||
const [tab, setTab] = useState<'details' | 'connection' | 'securityGroups' | 'tags' | 'console'>('details')
|
||||
const [showAmiModal, setShowAmiModal] = useState(false)
|
||||
const [showConsoleModal, setShowConsoleModal] = useState(false)
|
||||
const [terminateStep, setTerminateStep] = useState<'idle' | 'confirm'>('idle')
|
||||
@@ -510,6 +549,18 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
<button
|
||||
className={`sns-tab${tab === 'connection' ? ' active' : ''}`}
|
||||
onClick={() => setTab('connection')}
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
<button
|
||||
className={`sns-tab${tab === 'securityGroups' ? ' active' : ''}`}
|
||||
onClick={() => setTab('securityGroups')}
|
||||
>
|
||||
Security groups
|
||||
</button>
|
||||
<button
|
||||
className={`sns-tab${tab === 'tags' ? ' active' : ''}`}
|
||||
onClick={() => setTab('tags')}
|
||||
@@ -602,10 +653,38 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'connection' && (() => {
|
||||
const connection = inst.connection
|
||||
const command = connection?.sshHost && connection.sshPort && inst.keyName
|
||||
? `ssh -i ${inst.keyName}.pem -p ${connection.sshPort} root@${connection.sshHost}`
|
||||
: undefined
|
||||
if (state === 'stopped') {
|
||||
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>Start this instance before connecting over SSH.</p>
|
||||
}
|
||||
if (state === 'terminated') {
|
||||
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>Terminated instances cannot accept connections.</p>
|
||||
}
|
||||
if (state !== 'running') {
|
||||
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>SSH is unavailable while this instance is {state}.</p>
|
||||
}
|
||||
if (!command || connection?.containerStatus !== 'running') {
|
||||
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>SSH is unavailable. Backing container status: {connection?.containerStatus ?? 'unavailable'}.</p>
|
||||
}
|
||||
return <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div className="meta-row"><span className="meta-label">SSH port</span><span className="meta-value">{connection.sshPort}</span></div>
|
||||
<div className="meta-row"><span className="meta-label">Backing container</span><span className="meta-value">{connection.containerStatus}</span></div>
|
||||
<div className="meta-row" style={{ gridColumn: '1 / -1' }}><span className="meta-label">Command</span><code style={{ overflowWrap: 'anywhere' }}>{command}</code><CopyValueButton value={command} label="SSH command" /></div>
|
||||
</div>
|
||||
})()}
|
||||
|
||||
{tab === 'tags' && (
|
||||
<InlineTagEditor instanceId={instanceId} initialTags={inst.tags} />
|
||||
)}
|
||||
|
||||
{tab === 'securityGroups' && (
|
||||
<InlineSecurityGroupEditor instanceId={instanceId} vpcId={inst.vpcId} initialGroups={inst.securityGroups}/>
|
||||
)}
|
||||
|
||||
{tab === 'console' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>
|
||||
|
||||
@@ -61,3 +61,23 @@ test('edits key-value fields as variable rows', async () => {
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({environment: '[{"key":"APP_GREETING","value":"Hello"}]'})
|
||||
})
|
||||
|
||||
test('shows fields only when their visibility condition matches', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DynamicFormRenderer schema={{
|
||||
...schema,
|
||||
fields: [
|
||||
{name: 'kind', label: 'Kind', type: 'select', required: true, defaultValue: 'cluster', options: [{label: 'Cluster', value: 'cluster'}, {label: 'Service', value: 'service'}]},
|
||||
{name: 'clusterName', label: 'Cluster name', type: 'text', required: false, visibleWhen: {field: 'kind', values: ['cluster']}},
|
||||
{name: 'serviceName', label: 'Service name', type: 'text', required: false, visibleWhen: {field: 'kind', values: ['service']}},
|
||||
],
|
||||
}} isSubmitting={false} onSubmit={vi.fn()}/>)
|
||||
|
||||
expect(screen.getByLabelText('Cluster name')).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Service name')).not.toBeInTheDocument()
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox', {name: /Kind/}), 'service')
|
||||
|
||||
expect(screen.queryByLabelText('Cluster name')).not.toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Service name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -30,10 +30,11 @@ export function DynamicFormRenderer({
|
||||
getInitialFormValues(activeFields, initialValues),
|
||||
)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const visibleFields = activeFields.filter((field) => !field.visibleWhen || field.visibleWhen.values.includes(values[field.visibleWhen.field] ?? ''))
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
const nextErrors = validateValues(activeFields, values)
|
||||
const nextErrors = validateValues(visibleFields, values)
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
onSubmit(values)
|
||||
@@ -41,7 +42,7 @@ export function DynamicFormRenderer({
|
||||
|
||||
return (
|
||||
<form className="dynamic-form" onSubmit={submit} noValidate>
|
||||
{activeFields.map((field) => (
|
||||
{visibleFields.map((field) => (
|
||||
<FieldRow
|
||||
key={field.name}
|
||||
field={field}
|
||||
|
||||
@@ -65,9 +65,13 @@ import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPane
|
||||
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
||||
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
|
||||
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
|
||||
import { MskPanel } from "@/components/MskPanel";
|
||||
import { MskConnectPanel } from "@/components/MskConnectPanel";
|
||||
import { EcsServicePanel } from "@/components/EcsServicePanel";
|
||||
import {CodeDeployCreatePanel, CodeDeployPanel} from "@/components/CodeDeployPanel";
|
||||
import {WorkflowCreatePanel} from "@/components/WorkflowCreatePanel";
|
||||
import {KinesisPanel} from "@/components/KinesisPanel";
|
||||
import {SesConsolePanel} from '@/components/SesConsolePanel'
|
||||
import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate";
|
||||
import {workflowStudioPath} from "@/lib/workflowStudio";
|
||||
import {useNavigate, useSearchParams} from "react-router-dom";
|
||||
@@ -138,6 +142,7 @@ export function DynamicResourceView({
|
||||
const schemaQuery = useQuery({
|
||||
queryKey: ["cloud-schema", cloud, service],
|
||||
queryFn: ({ signal }) => getServiceSchema(cloud, service, signal),
|
||||
enabled: !(cloud === "aws" && (service === "msk" || service === "msk-connect")),
|
||||
});
|
||||
|
||||
const resourcesQuery = useQuery({
|
||||
@@ -496,6 +501,19 @@ export function DynamicResourceView({
|
||||
|
||||
const adapterAvailable = serviceAvailability === "available";
|
||||
|
||||
if (cloud === "aws" && service === "msk") {
|
||||
return (
|
||||
<div className="dynamic-resource-view">
|
||||
<TopbarServiceInfo onOpenInfo={onOpenInfo} />
|
||||
<MskPanel runtimeReachable={cloudStatus?.runtime === "reachable"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (cloud === "aws" && service === "msk-connect") {
|
||||
return <div className="dynamic-resource-view"><TopbarServiceInfo onOpenInfo={onOpenInfo} /><MskConnectPanel runtimeReachable={cloudStatus?.runtime === "reachable"} /></div>;
|
||||
}
|
||||
|
||||
if (schemaQuery.isLoading) {
|
||||
return (
|
||||
<div className="dynamic-resource-view">
|
||||
@@ -600,6 +618,7 @@ export function DynamicResourceView({
|
||||
className={`resource-workbench${activeSelected && !showDatabaseSnapshots ? " with-inspector" : ""}`}
|
||||
>
|
||||
<section className="resource-main">
|
||||
{cloud === 'aws' && service === 'email' && <SesConsolePanel runtimeReachable={canUseRuntime}/>}
|
||||
{isAwsDatabase && (
|
||||
<div className="drawer-tabs" style={{ marginBottom: 12 }}>
|
||||
<button
|
||||
@@ -966,6 +985,7 @@ export function DynamicResourceView({
|
||||
resource={activeSelected}
|
||||
runtimeReachable={canUseRuntime}
|
||||
/>
|
||||
<KinesisPanel cloud={cloud} resource={activeSelected} runtimeReachable={canUseRuntime} mode="lambda" />
|
||||
{cloud === "aws" && activeSelected?.type === "lambda" && (
|
||||
<LambdaInvocationHistoryPanel cloud={cloud} functionName={activeSelected.name} runtimeReachable={canUseRuntime}/>
|
||||
)}
|
||||
@@ -1020,6 +1040,7 @@ export function DynamicResourceView({
|
||||
{service === "codedeploy" && cloud === "aws" && (
|
||||
<CodeDeployPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "kinesis" && cloud === "aws" && <KinesisPanel cloud={cloud} resource={activeSelected} runtimeReachable={canUseRuntime} mode="stream" />}
|
||||
{editingResource && schema.updateFields && (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
|
||||
@@ -18,3 +18,19 @@ test('labels inactive services as not running instead of showing stale counts',
|
||||
expect(screen.getByText('Inactive, not running')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Running 2 of 2')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('shows cluster capacity and task counts', () => {
|
||||
render(<EcsServicePanel resource={{id: 'cluster:orders', name: 'orders', cloud: 'aws', service: 'ecs', type: 'ecs-cluster', region: 'us-east-1', createdAt: null, status: 'ACTIVE', metadata: {activeServicesCount: 2, runningTasksCount: 3, pendingTasksCount: 1, registeredContainerInstancesCount: 0}}}/>)
|
||||
|
||||
expect(screen.getByText('ECS cluster')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Services: 2/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Running tasks: 3/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('shows Fargate task definition container configuration', () => {
|
||||
render(<EcsServicePanel resource={{id: 'task-definition:orders:1', name: 'orders:1', cloud: 'aws', service: 'ecs', type: 'ecs-task-definition', region: 'us-east-1', createdAt: null, status: 'ACTIVE', metadata: {cpu: '256', memory: '512', networkMode: 'awsvpc', containerName: 'web', image: 'orders:latest', containerPort: 8080}}}/>)
|
||||
|
||||
expect(screen.getByText('Fargate task definition')).toBeInTheDocument()
|
||||
expect(screen.getByText('orders:latest')).toBeInTheDocument()
|
||||
expect(screen.getByText('8080')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -9,6 +9,8 @@ interface LoadBalancer { targetGroupArn?: string; targetType?: string | null; co
|
||||
|
||||
export function EcsServicePanel({resource}: EcsServicePanelProps) {
|
||||
if (!resource) return null
|
||||
if (resource.type === 'ecs-cluster') return <EcsClusterDetails resource={resource}/>
|
||||
if (resource.type === 'ecs-task-definition') return <EcsTaskDefinitionDetails resource={resource}/>
|
||||
const deployments = array<Deployment>(resource.metadata.deployments)
|
||||
const tasks = array<Task>(resource.metadata.tasks)
|
||||
const loadBalancers = array<LoadBalancer>(resource.metadata.loadBalancers)
|
||||
@@ -16,7 +18,7 @@ export function EcsServicePanel({resource}: EcsServicePanelProps) {
|
||||
const launchType = String(resource.metadata.launchType ?? 'EC2')
|
||||
const network = object(resource.metadata.networkConfiguration)
|
||||
return <section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">{launchType === 'FARGATE' ? 'Fargate service state' : 'EC2 service state'}</p><h3>{resource.name}</h3><p className="muted compact-text">Launch type: <strong>{launchType}</strong> | Cluster: {String(resource.metadata.cluster ?? '-')} | <span>{inactive ? 'Inactive, not running' : `Running ${String(resource.metadata.runningCount ?? 0)} of ${String(resource.metadata.desiredCount ?? 0)}`}</span></p></div></div>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">{launchType === 'FARGATE' ? 'Fargate service state' : 'EC2 service state'}</p><h3>{resource.name}</h3><p className="muted compact-text">Launch type: <strong>{launchType}</strong> | Deployment controller: <strong>{String(resource.metadata.deploymentController ?? 'ECS')}</strong> | Cluster: {String(resource.metadata.cluster ?? '-')} | <span>{inactive ? 'Inactive, not running' : `Running ${String(resource.metadata.runningCount ?? 0)} of ${String(resource.metadata.desiredCount ?? 0)}`}</span></p></div></div>
|
||||
{network && <p className="muted compact-text">awsvpc networking: subnets {values(network.subnets)} | security groups {values(network.securityGroups)} | public IP {String(network.assignPublicIp ?? 'DISABLED')}</p>}
|
||||
{launchType === 'FARGATE' && !network && <p className="muted compact-text">Fargate services require awsvpc networking. Attach an IP target group, not an instance target group, when using an ALB.</p>}
|
||||
{inactive ? <p className="muted compact-text">This service is inactive. ECS may retain historical deployment counts while deletion settles; they are not shown as running tasks.</p> : <>
|
||||
@@ -30,6 +32,20 @@ export function EcsServicePanel({resource}: EcsServicePanelProps) {
|
||||
</section>
|
||||
}
|
||||
|
||||
function EcsClusterDetails({resource}: {resource: CloudResource}) {
|
||||
return <section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">ECS cluster</p><h3>{resource.name}</h3><p className="muted compact-text">Status: <strong>{resource.status ?? '-'}</strong> | Services: {String(resource.metadata.activeServicesCount ?? 0)} | Running tasks: {String(resource.metadata.runningTasksCount ?? 0)} | Pending tasks: {String(resource.metadata.pendingTasksCount ?? 0)}</p></div></div>
|
||||
<p className="muted compact-text">Registered container instances: {String(resource.metadata.registeredContainerInstancesCount ?? 0)}</p>
|
||||
</section>
|
||||
}
|
||||
|
||||
function EcsTaskDefinitionDetails({resource}: {resource: CloudResource}) {
|
||||
return <section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Fargate task definition</p><h3>{resource.name}</h3><p className="muted compact-text">CPU: <strong>{String(resource.metadata.cpu ?? '-')}</strong> | Memory: <strong>{String(resource.metadata.memory ?? '-')} MiB</strong> | Network mode: <strong>{String(resource.metadata.networkMode ?? '-')}</strong></p></div></div>
|
||||
<div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Container</th><th>Image</th><th>Port</th></tr></thead><tbody><tr><td>{String(resource.metadata.containerName ?? '-')}</td><td><code>{String(resource.metadata.image ?? '-')}</code></td><td>{String(resource.metadata.containerPort ?? '-')}</td></tr></tbody></table></div>
|
||||
</section>
|
||||
}
|
||||
|
||||
function array<T>(value: unknown): T[] { return Array.isArray(value) ? value as T[] : [] }
|
||||
function object(value: unknown): Record<string, unknown> | null { return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null }
|
||||
function values(value: unknown): string { return Array.isArray(value) && value.length ? value.map(String).join(', ') : '-' }
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen, waitFor} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {expect, test, vi} from 'vitest'
|
||||
import {KinesisPanel} from './KinesisPanel'
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({createLambdaEventSourceMapping: vi.fn(), deleteLambdaEventSourceMapping: vi.fn(), listCloudResources: vi.fn(), listKinesisRecords: vi.fn(), listLambdaEventSourceMappings: vi.fn(), putKinesisRecord: vi.fn(), updateLambdaEventSourceMapping: vi.fn()}))
|
||||
vi.mock('@/api/cloudProxyClient', () => apiMocks)
|
||||
|
||||
const stream = {id: 'orders', name: 'orders', cloud: 'aws' as const, service: 'kinesis' as const, type: 'stream', region: null, createdAt: null, metadata: {arn: 'arn:aws:kinesis:us-east-1:000000000000:stream/orders'}}
|
||||
const lambda = {id: 'worker', name: 'worker', cloud: 'aws' as const, service: 'serverless' as const, type: 'lambda', region: null, createdAt: null, metadata: {}}
|
||||
|
||||
test('produces records and creates a Kinesis trigger from the stream view', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listKinesisRecords.mockResolvedValue([])
|
||||
apiMocks.listLambdaEventSourceMappings.mockResolvedValue([])
|
||||
apiMocks.putKinesisRecord.mockResolvedValue({sequenceNumber: '1', shardId: 'shard-000'})
|
||||
apiMocks.createLambdaEventSourceMapping.mockResolvedValue({id: 'mapping-1'})
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
render(<QueryClientProvider client={queryClient}><KinesisPanel cloud="aws" resource={stream} runtimeReachable mode="stream" /></QueryClientProvider>)
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', {name: /put record/i}))
|
||||
await waitFor(() => expect(apiMocks.putKinesisRecord).toHaveBeenCalledWith('aws', 'orders', {partitionKey: 'console', data: '{}'}))
|
||||
await user.selectOptions(await screen.findByLabelText(/lambda function/i), 'worker')
|
||||
await user.click(screen.getByRole('button', {name: /add trigger/i}))
|
||||
await waitFor(() => expect(apiMocks.createLambdaEventSourceMapping).toHaveBeenCalledWith('aws', {functionName: 'worker', eventSourceArn: stream.metadata.arn, batchSize: 100, startingPosition: 'LATEST'}))
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Loader2, Plus, RefreshCw, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {createLambdaEventSourceMapping, deleteLambdaEventSourceMapping, listCloudResources, listKinesisRecords, listLambdaEventSourceMappings, putKinesisRecord, updateLambdaEventSourceMapping, type LambdaEventSourceMappingOverview} from '@/api/cloudProxyClient'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
interface KinesisPanelProps {cloud: CloudProvider; resource?: CloudResource; runtimeReachable: boolean; mode: 'stream' | 'lambda'}
|
||||
|
||||
export function KinesisPanel({cloud, resource, runtimeReachable, mode}: KinesisPanelProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [partitionKey, setPartitionKey] = useState('console')
|
||||
const [data, setData] = useState('{}')
|
||||
const [functionName, setFunctionName] = useState('')
|
||||
const [streamArn, setStreamArn] = useState('')
|
||||
const [batchSize, setBatchSize] = useState('100')
|
||||
const [startingPosition, setStartingPosition] = useState<'TRIM_HORIZON' | 'LATEST'>('LATEST')
|
||||
const [deleteTarget, setDeleteTarget] = useState<LambdaEventSourceMappingOverview | null>(null)
|
||||
const streamsQuery = useQuery({queryKey: ['cloud-resources', cloud, 'kinesis'], queryFn: ({signal}) => listCloudResources(cloud, 'kinesis', undefined, signal), enabled: mode === 'lambda' && cloud === 'aws' && runtimeReachable})
|
||||
const functionsQuery = useQuery({queryKey: ['cloud-resources', cloud, 'serverless'], queryFn: ({signal}) => listCloudResources(cloud, 'serverless', undefined, signal), enabled: mode === 'stream' && cloud === 'aws' && runtimeReachable})
|
||||
const streamName = mode === 'stream' ? resource?.id : undefined
|
||||
const sourceArn = mode === 'stream' ? resource?.metadata.arn as string | undefined : streamArn
|
||||
const lambdaName = mode === 'lambda' ? resource?.name : functionName
|
||||
const recordsQuery = useQuery({queryKey: ['kinesis-records', cloud, streamName], queryFn: ({signal}) => listKinesisRecords(cloud, streamName!, signal), enabled: mode === 'stream' && Boolean(streamName) && runtimeReachable})
|
||||
const mappingsQuery = useQuery({queryKey: ['kinesis-lambda-mappings', cloud, sourceArn, lambdaName], queryFn: ({signal}) => listLambdaEventSourceMappings(cloud, mode === 'stream' ? {eventSourceArn: sourceArn} : {functionName: lambdaName}, signal), enabled: Boolean(sourceArn || lambdaName) && runtimeReachable})
|
||||
useEffect(() => { setFunctionName(''); setStreamArn(''); setDeleteTarget(null) }, [resource?.id])
|
||||
const putMutation = useMutation({mutationFn: () => putKinesisRecord(cloud, streamName!, {partitionKey, data}), onSuccess: () => queryClient.invalidateQueries({queryKey: ['kinesis-records', cloud, streamName]})})
|
||||
const createMutation = useMutation({mutationFn: () => createLambdaEventSourceMapping(cloud, {functionName: lambdaName!, eventSourceArn: sourceArn!, batchSize: Number(batchSize), startingPosition}), onSuccess: () => queryClient.invalidateQueries({queryKey: ['kinesis-lambda-mappings']})})
|
||||
const updateMutation = useMutation({mutationFn: ({id, enabled}: {id: string; enabled: boolean}) => updateLambdaEventSourceMapping(cloud, id, {enabled}), onSuccess: () => queryClient.invalidateQueries({queryKey: ['kinesis-lambda-mappings']})})
|
||||
const deleteMutation = useMutation({mutationFn: (id: string) => deleteLambdaEventSourceMapping(cloud, id), onSuccess: () => { setDeleteTarget(null); queryClient.invalidateQueries({queryKey: ['kinesis-lambda-mappings']}) }})
|
||||
if (!resource || (mode === 'stream' && resource.service !== 'kinesis') || (mode === 'lambda' && resource.type !== 'lambda')) return null
|
||||
const canCreate = Boolean(lambdaName && sourceArn) && Number.isInteger(Number(batchSize)) && Number(batchSize) >= 1 && Number(batchSize) <= 10_000
|
||||
const mappings = (mappingsQuery.data ?? []).filter((mapping) => mapping.eventSourceArn.includes(':kinesis:'))
|
||||
return <div className="kinesis-records-grid">
|
||||
<section className="table-panel">
|
||||
{mode === 'stream' && <><div className="dynamic-stage-header"><div><p className="eyebrow">Records</p><h3>Produce records</h3></div></div><form className="inspector-action-form apigateway-route-form" onSubmit={(event) => { event.preventDefault(); putMutation.mutate() }}><label className="dynamic-field"><span>Partition key</span><input className="input" value={partitionKey} onChange={(event) => setPartitionKey(event.target.value)} required/></label><label className="dynamic-field"><span>Data</span><textarea className="input" value={data} onChange={(event) => setData(event.target.value)} required/></label><button className="button primary" type="submit" disabled={!partitionKey || putMutation.isPending}>{putMutation.isPending ? <Loader2 size={13}/> : <Plus size={13}/>} Put record</button></form>{putMutation.isError && <p className="error-text compact-text">{putMutation.error instanceof Error ? putMutation.error.message : 'Unable to put record.'}</p>}</>}
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Event Source</p><h3>Add Kinesis trigger</h3><p className="muted compact-text">Configure Lambda polling through an AWS Lambda event source mapping.</p></div></div>
|
||||
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => { event.preventDefault(); if (canCreate) createMutation.mutate() }}>
|
||||
{mode === 'stream' ? <label className="dynamic-field"><span>Lambda function</span><select className="input" value={functionName} onChange={(event) => setFunctionName(event.target.value)} required><option value="">Select a function</option>{functionsQuery.data?.filter((candidate) => candidate.type === 'lambda').map((fn) => <option key={fn.id} value={fn.name}>{fn.name}</option>)}</select></label> : <label className="dynamic-field"><span>Kinesis stream</span><select className="input" value={streamArn} onChange={(event) => setStreamArn(event.target.value)} required><option value="">Select a stream</option>{streamsQuery.data?.map((stream) => typeof stream.metadata.arn === 'string' && <option key={stream.id} value={stream.metadata.arn}>{stream.name}</option>)}</select></label>}
|
||||
<label className="dynamic-field"><span>Starting position</span><select className="input" value={startingPosition} onChange={(event) => setStartingPosition(event.target.value as 'TRIM_HORIZON' | 'LATEST')}><option value="LATEST">Latest</option><option value="TRIM_HORIZON">Trim horizon</option></select></label><label className="dynamic-field"><span>Batch size</span><input className="input" type="number" min="1" max="10000" value={batchSize} onChange={(event) => setBatchSize(event.target.value)} required/></label><button className="button primary" type="submit" disabled={!canCreate || createMutation.isPending}>{createMutation.isPending ? <Loader2 size={13}/> : <Plus size={13}/>} Add trigger</button>
|
||||
</form>{createMutation.isError && <p className="error-text compact-text">{createMutation.error instanceof Error ? createMutation.error.message : 'Unable to add Kinesis trigger.'}</p>}<div className="apigateway-route-explorer"><div className="dynamic-stage-header"><div><p className="eyebrow">Configured Triggers</p><h3>Kinesis event source mappings</h3></div></div>{mappingsQuery.isSuccess && mappings.length === 0 && <p className="muted compact-text">No Kinesis triggers configured.</p>}{mappings.map((mapping) => <div className="apigateway-route-item" key={mapping.id}><div><code>{mapping.eventSourceArn}</code><small>{mapping.functionName ?? mapping.functionArn ?? 'Lambda'} · Batch size: {mapping.batchSize ?? 'default'} · {mapping.state ?? 'unknown'}</small></div>{deleteTarget?.id === mapping.id ? <div className="apigateway-route-confirm"><span>Remove trigger?</span><button className="button danger compact" type="button" onClick={() => deleteMutation.mutate(mapping.id)}>Remove</button><button className="button compact" type="button" onClick={() => setDeleteTarget(null)}>Cancel</button></div> : <div><button className="button compact" type="button" onClick={() => updateMutation.mutate({id: mapping.id, enabled: mapping.state !== 'Enabled'})}>{mapping.state === 'Enabled' ? 'Disable' : 'Enable'}</button><button className="icon-btn" type="button" aria-label="Remove Kinesis trigger" onClick={() => setDeleteTarget(mapping)}><Trash2 size={13}/></button></div>}</div>)}</div></section>
|
||||
{mode === 'stream' && <section className="table-panel"><div className="apigateway-route-explorer"><div className="dynamic-stage-header"><div><p className="eyebrow">Inspection</p><h3>Stream records</h3></div><button className="button compact" type="button" onClick={() => recordsQuery.refetch()}><RefreshCw size={13}/> Refresh</button></div>{recordsQuery.data?.length === 0 && <p className="muted compact-text">No records in this stream.</p>}<div className="kinesis-record-list">{recordsQuery.data?.map((record) => <div className="apigateway-route-item" key={record.sequenceNumber}><div><code>{record.partitionKey ?? 'no partition key'}</code><small>{record.sequenceNumber}</small><pre className="compact-text">{record.data}</pre></div></div>)}</div></div></section>}
|
||||
</div>
|
||||
}
|
||||
@@ -175,11 +175,11 @@ export function Layout() {
|
||||
<a className="skip-link" href="#main-content">Skip to content</a>
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-inner">
|
||||
<div className="brand">
|
||||
<NavLink className="brand" to={`/console/${activeCloud}`} aria-label="The Cloud console home">
|
||||
<img className="brand-logo" src={theCloud} alt="The Cloud"/>
|
||||
<img className="brand-mark" src={theCloud} alt="" aria-hidden="true"/>
|
||||
<p>The Cloud</p>
|
||||
</div>
|
||||
</NavLink>
|
||||
|
||||
<nav className="nav" aria-label="Console">
|
||||
<div className="nav-section">
|
||||
|
||||
@@ -24,6 +24,8 @@ test('creates and deletes selected ALB HTTP listeners through generic cloud prox
|
||||
render(<QueryClientProvider client={client}><LoadBalancerListenerPanel runtimeReachable resource={{id: 'load-balancer-arn', name: 'web', cloud: 'aws', service: 'loadbalancing', type: 'load-balancer', region: null, createdAt: null, metadata: {}}}/></QueryClientProvider>)
|
||||
|
||||
await screen.findByText('web-targets (target-group-arn)')
|
||||
expect(screen.getByText('listener-arn')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', {name: 'Copy Listener ARN'})).toBeInTheDocument()
|
||||
await user.selectOptions(screen.getByLabelText(/default target group/i), 'target-group-arn')
|
||||
await user.clear(screen.getByLabelText(/listener port/i))
|
||||
await user.type(screen.getByLabelText(/listener port/i), '8080')
|
||||
|
||||
@@ -2,6 +2,7 @@ import {RefreshCw, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {deleteFlatItem, listCloudResources, listFlatItems, putFlatItem} from '@/api/cloudProxyClient'
|
||||
import {CopyValueButton} from '@/components/CopyValueButton'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
@@ -65,7 +66,7 @@ export function LoadBalancerListenerPanel({resource, runtimeReachable}: Props) {
|
||||
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||
{listenersQuery.isLoading && <div className="muted padded">Loading listeners</div>}
|
||||
{listenersQuery.isSuccess && listeners.length === 0 && <div className="empty compact"><h3>No listeners</h3><p>Create an HTTP listener that forwards requests to a target group.</p></div>}
|
||||
{listeners.length > 0 && <div className="cosmos-items-table"><table><thead><tr><th>Protocol</th><th>Port</th><th>Default target group</th><th/></tr></thead><tbody>{listeners.map((listener) => <tr key={listener.id}><td>{stringValue(listener.body.protocol)}</td><td>{stringValue(listener.body.port)}</td><td><code>{stringValue(listener.body.targetGroupArn)}</code></td><td><button className="icon-btn danger" type="button" title="Delete listener" aria-label={`Delete listener ${stringValue(listener.body.port)}`} disabled={!runtimeReachable || pending} onClick={() => deleteMutation.mutate(listener.id)}><Trash2 size={14}/></button></td></tr>)}</tbody></table></div>}
|
||||
{listeners.length > 0 && <div className="cosmos-items-table"><table><thead><tr><th>Protocol</th><th>Port</th><th>Listener ARN</th><th>Default target group</th><th/></tr></thead><tbody>{listeners.map((listener) => <tr key={listener.id}><td>{stringValue(listener.body.protocol)}</td><td>{stringValue(listener.body.port)}</td><td><span className="copy-value mono">{listener.id}<CopyValueButton value={listener.id} label="Listener ARN"/></span></td><td><code>{stringValue(listener.body.targetGroupArn)}</code></td><td><button className="icon-btn danger" type="button" title="Delete listener" aria-label={`Delete listener ${stringValue(listener.body.port)}`} disabled={!runtimeReachable || pending} onClick={() => deleteMutation.mutate(listener.id)}><Trash2 size={14}/></button></td></tr>)}</tbody></table></div>}
|
||||
</section>
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {Link} from "react-router-dom";
|
||||
import {Loader2, Plus, RefreshCw, Trash2} from "lucide-react";
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
|
||||
import {listCloudResources} from "@/api/cloudProxyClient";
|
||||
import {getMskBootstrapBrokers, listMskClusters} from "@/api/mskClient";
|
||||
import {createS3SinkConnector, deleteConnector, deleteCustomPlugin, describeConnector, listConnectors, listCustomPlugins, registerCustomPlugin, updateS3SinkConnector, type ConnectorDescription, type S3SinkConnectorInput} from "@/api/mskConnectClient";
|
||||
|
||||
const defaults: S3SinkConnectorInput = {
|
||||
connectorName: "", bootstrapServers: "", kafkaConnectVersion: "3.7.x", serviceExecutionRoleArn: "", customPluginArn: "", customPluginRevision: 1,
|
||||
workerCount: 1, mcuCount: 1, topics: "", keyConverter: "string", valueConverter: "json", s3Bucket: "", s3Region: "us-east-1", flushSize: "1", rotateIntervalMs: "60000", logGroup: "",
|
||||
};
|
||||
|
||||
export function MskConnectPanel({runtimeReachable}: {runtimeReachable: boolean}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [plugin, setPlugin] = useState({name: "", bucket: "", fileKey: "", objectVersion: ""});
|
||||
const [form, setForm] = useState<S3SinkConnectorInput>(defaults);
|
||||
const [selectedArn, setSelectedArn] = useState("");
|
||||
const pluginsQuery = useQuery({queryKey: ["msk-connect-plugins"], queryFn: ({signal}) => listCustomPlugins(signal), enabled: runtimeReachable});
|
||||
const clustersQuery = useQuery({queryKey: ["msk-connect-clusters"], queryFn: ({signal}) => listMskClusters(signal), enabled: runtimeReachable});
|
||||
const connectorsQuery = useQuery({queryKey: ["msk-connect-connectors"], queryFn: ({signal}) => listConnectors(signal), enabled: runtimeReachable});
|
||||
const bucketsQuery = useQuery({queryKey: ["cloud-resources", "aws", "storage", "msk-connect"], queryFn: ({signal}) => listCloudResources("aws", "storage", "", signal), enabled: runtimeReachable});
|
||||
const connectorQuery = useQuery({queryKey: ["msk-connect-connector", selectedArn], queryFn: ({signal}) => describeConnector(selectedArn, signal), enabled: runtimeReachable && Boolean(selectedArn)});
|
||||
const registerPlugin = useMutation({mutationFn: () => registerCustomPlugin(plugin), onSuccess: () => { setPlugin({name: "", bucket: "", fileKey: "", objectVersion: ""}); void queryClient.invalidateQueries({queryKey: ["msk-connect-plugins"]}); }});
|
||||
const removePlugin = useMutation({mutationFn: (customPluginArn: string) => deleteCustomPlugin(customPluginArn), onSuccess: () => void queryClient.invalidateQueries({queryKey: ["msk-connect-plugins"]})});
|
||||
const createConnector = useMutation({mutationFn: () => createS3SinkConnector(form), onSuccess: () => { setForm(defaults); void queryClient.invalidateQueries({queryKey: ["msk-connect-connectors"]}); }});
|
||||
const updateConnector = useMutation({mutationFn: (connector: ConnectorDescription) => updateS3SinkConnector(selectedArn, {...form, currentVersion: connector.currentVersion ?? ""}), onSuccess: () => void queryClient.invalidateQueries({queryKey: ["msk-connect-connector", selectedArn]})});
|
||||
const removeConnector = useMutation({mutationFn: () => deleteConnector(selectedArn), onSuccess: () => { setSelectedArn(""); void queryClient.invalidateQueries({queryKey: ["msk-connect-connectors"]}); }});
|
||||
|
||||
useEffect(() => { if (!selectedArn && connectorsQuery.data?.[0]?.connectorArn) setSelectedArn(connectorsQuery.data[0].connectorArn); }, [connectorsQuery.data, selectedArn]);
|
||||
useEffect(() => {
|
||||
const connector = connectorQuery.data;
|
||||
if (!connector) return;
|
||||
const configuration = connector.connectorConfiguration ?? {};
|
||||
const capacity = connector.capacity?.provisionedCapacity;
|
||||
setForm((value) => ({...value, workerCount: capacity?.workerCount ?? value.workerCount, mcuCount: capacity?.mcuCount ?? value.mcuCount, topics: configuration.topics ?? value.topics, s3Bucket: configuration["s3.bucket.name"] ?? value.s3Bucket, s3Region: configuration["s3.region"] ?? value.s3Region, flushSize: configuration["flush.size"] ?? value.flushSize, rotateIntervalMs: configuration["rotate.interval.ms"] ?? value.rotateIntervalMs, keyConverter: configuration["key.converter"]?.includes("Json") ? "json" : "string", valueConverter: configuration["value.converter"]?.includes("String") ? "string" : "json"}));
|
||||
}, [connectorQuery.data]);
|
||||
|
||||
const set = <K extends keyof S3SinkConnectorInput>(key: K, value: S3SinkConnectorInput[K]) => setForm((current) => ({...current, [key]: value}));
|
||||
const error = registerPlugin.error ?? removePlugin.error ?? createConnector.error ?? updateConnector.error ?? removeConnector.error;
|
||||
const canCreate = form.connectorName.trim() && form.bootstrapServers.trim() && form.serviceExecutionRoleArn.trim() && form.customPluginArn && form.topics.trim() && form.s3Bucket;
|
||||
|
||||
return <div className="sns-topic-workspace msk-workspace">
|
||||
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">MSK Connect</p><h3>Custom plugins</h3><p className="muted compact-text">Register plugin ZIP archives from S3 using the AWS CreateCustomPlugin shape.</p></div><Link className="button" to="/cloud-explorer/aws/storage">Browse S3</Link></div>
|
||||
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (plugin.name && plugin.bucket && plugin.fileKey) registerPlugin.mutate(); }}><label className="dynamic-field"><span>Plugin name</span><input className="input" value={plugin.name} onChange={(event) => setPlugin({...plugin, name: event.target.value})} required/></label><BucketField label="Plugin S3 bucket" buckets={bucketsQuery.data?.map((bucket) => bucket.name) ?? []} value={plugin.bucket} onChange={(bucket) => setPlugin({...plugin, bucket})}/><label className="dynamic-field"><span>ZIP object key</span><input className="input" value={plugin.fileKey} onChange={(event) => setPlugin({...plugin, fileKey: event.target.value})} placeholder="plugins/s3-sink.zip" required/></label><label className="dynamic-field"><span>Object version (optional)</span><input className="input" value={plugin.objectVersion} onChange={(event) => setPlugin({...plugin, objectVersion: event.target.value})}/></label><button className="button primary" disabled={!runtimeReachable || registerPlugin.isPending} type="submit">{registerPlugin.isPending ? <Loader2 size={13} className="spin"/> : <Plus size={13}/> }Register plugin</button></form>
|
||||
{pluginsQuery.data?.map((item) => <div className="apigateway-route-item" key={item.customPluginArn}><div className="apigateway-route-select"><code>{item.name}</code><small>Revision {item.revision ?? 1} {item.customPluginState ? `(${item.customPluginState})` : ""}</small></div><button className="button danger" type="button" disabled={!item.customPluginArn || removePlugin.isPending} onClick={() => { if (item.customPluginArn) removePlugin.mutate(item.customPluginArn); }}><Trash2 size={13}/>Delete plugin</button></div>)}
|
||||
</section>
|
||||
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">S3 Sink</p><h3>Create connector</h3><p className="muted compact-text">Uses the Confluent S3 Sink connector configuration, not an editable raw JSON payload.</p></div><button className="button" type="button" onClick={() => connectorsQuery.refetch()} disabled={!runtimeReachable || connectorsQuery.isFetching}><RefreshCw size={13}/>Refresh</button></div>
|
||||
<label className="dynamic-field"><span>MSK cluster</span><select className="input" defaultValue="" onChange={(event) => { const clusterArn = event.target.value; if (clusterArn) { void getMskBootstrapBrokers(clusterArn).then((bootstrapBrokers) => set("bootstrapServers", bootstrapBrokers)); } }}><option value="">Select a cluster</option>{clustersQuery.data?.filter((cluster) => cluster.State === "ACTIVE").map((cluster) => <option key={cluster.ClusterArn} value={cluster.ClusterArn}>{cluster.ClusterName}</option>)}</select></label>
|
||||
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (canCreate) createConnector.mutate(); }}><label className="dynamic-field"><span>Connector name</span><input className="input" value={form.connectorName} onChange={(event) => set("connectorName", event.target.value)} required/></label><label className="dynamic-field"><span>Bootstrap servers</span><input className="input" value={form.bootstrapServers} onChange={(event) => set("bootstrapServers", event.target.value)} placeholder="broker:9092" required/></label><label className="dynamic-field"><span>Kafka Connect version</span><input className="input" value={form.kafkaConnectVersion} onChange={(event) => set("kafkaConnectVersion", event.target.value)} required/></label><label className="dynamic-field"><span>Service execution role ARN</span><input className="input" value={form.serviceExecutionRoleArn} onChange={(event) => set("serviceExecutionRoleArn", event.target.value)} required/></label><label className="dynamic-field"><span>Custom plugin</span><select className="input" value={`${form.customPluginArn}:${form.customPluginRevision}`} onChange={(event) => { const item = pluginsQuery.data?.find((candidate) => `${candidate.customPluginArn}:${candidate.revision ?? 1}` === event.target.value); if (item?.customPluginArn) { set("customPluginArn", item.customPluginArn); set("customPluginRevision", item.revision ?? 1); } }} required><option value=":1">Select a plugin</option>{pluginsQuery.data?.map((item) => <option key={`${item.customPluginArn}:${item.revision}`} value={`${item.customPluginArn}:${item.revision ?? 1}`}>{item.name} (rev {item.revision ?? 1})</option>)}</select></label><label className="dynamic-field"><span>Workers</span><input className="input" type="number" min="1" value={form.workerCount} onChange={(event) => set("workerCount", Number(event.target.value))}/></label><label className="dynamic-field"><span>MCUs per worker</span><select className="input" value={form.mcuCount} onChange={(event) => set("mcuCount", Number(event.target.value))}>{[1, 2, 4, 8].map((value) => <option key={value} value={value}>{value}</option>)}</select></label><label className="dynamic-field"><span>Topics</span><input className="input" value={form.topics} onChange={(event) => set("topics", event.target.value)} placeholder="orders,events" required/></label><ConverterField label="Key converter" value={form.keyConverter} onChange={(value) => set("keyConverter", value)}/><ConverterField label="Value converter" value={form.valueConverter} onChange={(value) => set("valueConverter", value)}/><BucketField label="S3 destination bucket" buckets={bucketsQuery.data?.map((bucket) => bucket.name) ?? []} value={form.s3Bucket} onChange={(value) => set("s3Bucket", value)}/><label className="dynamic-field"><span>S3 region</span><input className="input" value={form.s3Region} onChange={(event) => set("s3Region", event.target.value)} required/></label><label className="dynamic-field"><span>Flush size</span><input className="input" type="number" min="1" value={form.flushSize} onChange={(event) => set("flushSize", event.target.value)} required/></label><label className="dynamic-field"><span>Rotate interval (ms)</span><input className="input" type="number" min="0" value={form.rotateIntervalMs} onChange={(event) => set("rotateIntervalMs", event.target.value)} required/></label><label className="dynamic-field"><span>CloudWatch log group (optional)</span><input className="input" value={form.logGroup} onChange={(event) => set("logGroup", event.target.value)} placeholder="/aws/msk-connect/s3-sink"/></label><button className="button primary" type="submit" disabled={!runtimeReachable || !canCreate || createConnector.isPending}>{createConnector.isPending ? "Creating" : "Create S3 Sink"}</button></form>
|
||||
</section>
|
||||
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Connectors</p><h3>State and configuration</h3><p className="muted compact-text">MSK Connect exposes connector state through DescribeConnector. It does not expose per-task status in its control-plane API.</p></div></div><label className="dynamic-field"><span>Connector</span><select className="input" value={selectedArn} onChange={(event) => setSelectedArn(event.target.value)}><option value="">Select a connector</option>{connectorsQuery.data?.map((item) => <option key={item.connectorArn} value={item.connectorArn}>{item.connectorName} {item.connectorState ? `(${item.connectorState})` : ""}</option>)}</select></label>{connectorQuery.data && <ConnectorStatus connector={connectorQuery.data}/>} {selectedArn && connectorQuery.data && <div className="button-row"><button className="button" type="button" disabled={updateConnector.isPending || !connectorQuery.data.currentVersion} onClick={() => updateConnector.mutate(connectorQuery.data)}>{updateConnector.isPending ? "Updating" : "Update S3 Sink settings"}</button><button className="button danger" type="button" disabled={removeConnector.isPending} onClick={() => removeConnector.mutate()}><Trash2 size={13}/>Delete connector</button></div>}{error instanceof Error && <p className="error-text compact-text">{error.message}</p>}</section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function BucketField({label, buckets, value, onChange}: {label: string; buckets: string[]; value: string; onChange: (value: string) => void}) { return <label className="dynamic-field"><span>{label}</span><select className="input" value={value} onChange={(event) => onChange(event.target.value)} required><option value="">Select a bucket</option>{buckets.map((bucket) => <option key={bucket} value={bucket}>{bucket}</option>)}</select>{value && <Link to={`/cloud-explorer/aws/storage?resource=${encodeURIComponent(value)}`}>Open in S3 browser</Link>}</label>; }
|
||||
function ConverterField({label, value, onChange}: {label: string; value: "string" | "json"; onChange: (value: "string" | "json") => void}) { return <label className="dynamic-field"><span>{label}</span><select className="input" value={value} onChange={(event) => onChange(event.target.value as "string" | "json")}><option value="string">String</option><option value="json">JSON</option></select></label>; }
|
||||
function ConnectorStatus({connector}: {connector: ConnectorDescription}) { const logGroup = connector.logDelivery?.workerLogDelivery?.cloudWatchLogs?.logGroup; return <div className="apigateway-route-item"><div className="apigateway-route-select"><strong>{connector.connectorState ?? "Unknown"}</strong>{connector.stateDescription && <small>{connector.stateDescription}</small>}<small>Task status: not available from the MSK Connect API</small>{logGroup && <Link to={`/cloud-explorer/aws/logs?resource=${encodeURIComponent(logGroup)}`}>Open {logGroup} in CloudWatch Logs</Link>}</div></div>; }
|
||||
@@ -0,0 +1,42 @@
|
||||
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
||||
import {render, screen, waitFor} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import {beforeEach, expect, test, vi} from "vitest";
|
||||
import {MskPanel} from "./MskPanel";
|
||||
|
||||
const api = vi.hoisted(() => ({createMskCluster: vi.fn(), createMskTopic: vi.fn(), deleteMskTopic: vi.fn(), getMskConsumerGroupLag: vi.fn(), listMskClusters: vi.fn(), listMskTopics: vi.fn(), peekMskMessages: vi.fn(), produceMskMessage: vi.fn()}));
|
||||
vi.mock("@/api/mskClient", () => api);
|
||||
|
||||
function renderPanel() {
|
||||
return render(<QueryClientProvider client={new QueryClient({defaultOptions: {queries: {retry: false}}})}><MskPanel runtimeReachable /></QueryClientProvider>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
api.listMskClusters.mockResolvedValue([{ClusterArn: "arn:cluster:orders", ClusterName: "orders"}]);
|
||||
api.listMskTopics.mockResolvedValue([{name: "orders", partitionCount: 1}]);
|
||||
});
|
||||
|
||||
test("uses MSK routes for topic, message, and consumer group operations", async () => {
|
||||
api.produceMskMessage.mockResolvedValue([{topicName: "orders", partition: 0, baseOffset: "1"}]);
|
||||
api.peekMskMessages.mockResolvedValue([{partition: 0, offset: "1", timestamp: "0", value: "hello", headers: {}}]);
|
||||
api.getMskConsumerGroupLag.mockResolvedValue([{topic: "orders", totalLag: 2, partitions: [{partition: 0, currentOffset: "1", endOffset: "3", lag: 2}]}]);
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText("1 partition");
|
||||
await user.selectOptions(screen.getAllByLabelText("Topic")[1], "orders");
|
||||
await user.type(screen.getByLabelText("Value"), "hello");
|
||||
await user.click(screen.getByRole("button", {name: "Produce message"}));
|
||||
await waitFor(() => expect(api.produceMskMessage).toHaveBeenCalledWith("arn:cluster:orders", "orders", {value: "hello"}));
|
||||
await user.click(screen.getByRole("button", {name: "Peek messages"}));
|
||||
expect(await screen.findByText(/Partition 0.*Offset 1/)).toBeInTheDocument();
|
||||
await user.type(screen.getByLabelText("Consumer group ID"), "orders-workers");
|
||||
await user.click(screen.getByRole("button", {name: "View lag"}));
|
||||
expect(await screen.findByText("Total lag: 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("labels the intentional local compatibility limits", () => {
|
||||
renderPanel();
|
||||
expect(screen.getByText(/no IAM, SASL, or TLS authentication/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/enforcement unverified/i)).not.toHaveLength(0);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {Loader2, Plus, RefreshCw, Send, Trash2} from "lucide-react";
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
|
||||
import {createMskCluster, createMskTopic, deleteMskCluster, deleteMskTopic, getMskConsumerGroupLag, listMskClusters, listMskTopics, peekMskMessages, produceMskMessage} from "@/api/mskClient";
|
||||
import {listEc2Subnets} from "@/api/aws/ec2.api";
|
||||
|
||||
export function MskPanel({runtimeReachable}: {runtimeReachable: boolean}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [clusterArn, setClusterArn] = useState("");
|
||||
const [deleteClusterConfirmation, setDeleteClusterConfirmation] = useState(false);
|
||||
const [clusterName, setClusterName] = useState("");
|
||||
const [brokers, setBrokers] = useState("1");
|
||||
const [version, setVersion] = useState("3.7.x");
|
||||
const [subnets, setSubnets] = useState<string[]>([]);
|
||||
const [topic, setTopic] = useState("");
|
||||
const [partitions, setPartitions] = useState("1");
|
||||
const [retentionMs, setRetentionMs] = useState("");
|
||||
const [messageTopic, setMessageTopic] = useState("");
|
||||
const [messageKey, setMessageKey] = useState("");
|
||||
const [messageValue, setMessageValue] = useState("");
|
||||
const [groupId, setGroupId] = useState("");
|
||||
const [messages, setMessages] = useState<Awaited<ReturnType<typeof peekMskMessages>>>([]);
|
||||
const [lag, setLag] = useState<Awaited<ReturnType<typeof getMskConsumerGroupLag>>>([]);
|
||||
const clustersQuery = useQuery({queryKey: ["msk-clusters"], queryFn: ({signal}) => listMskClusters(signal), enabled: runtimeReachable});
|
||||
const subnetsQuery = useQuery({queryKey: ["ec2", "subnets"], queryFn: ({signal}) => listEc2Subnets(undefined, signal), enabled: runtimeReachable});
|
||||
const topicsQuery = useQuery({queryKey: ["msk-topics", clusterArn], queryFn: ({signal}) => listMskTopics(clusterArn, signal), enabled: runtimeReachable && Boolean(clusterArn)});
|
||||
|
||||
useEffect(() => { const firstArn = clustersQuery.data?.[0]?.ClusterArn; if (!clusterArn && firstArn) setClusterArn(firstArn); }, [clusterArn, clustersQuery.data]);
|
||||
useEffect(() => { setMessageTopic(""); setMessages([]); setLag([]); }, [clusterArn]);
|
||||
|
||||
const createCluster = useMutation({mutationFn: () => createMskCluster({clusterName: clusterName.trim(), kafkaVersion: version.trim(), numberOfBrokerNodes: Number(brokers), brokerNodeGroupInfo: {clientSubnets: subnets}}), onSuccess: () => { setClusterName(""); void queryClient.invalidateQueries({queryKey: ["msk-clusters"]}); }});
|
||||
const deleteCluster = useMutation({mutationFn: () => deleteMskCluster(clusterArn), onSuccess: () => { setClusterArn(""); setDeleteClusterConfirmation(false); void queryClient.invalidateQueries({queryKey: ["msk-clusters"]}); }});
|
||||
const createTopic = useMutation({mutationFn: () => createMskTopic(clusterArn, {topic: topic.trim(), numPartitions: Number(partitions), ...(retentionMs.trim() ? {configEntries: [{name: "retention.ms", value: retentionMs.trim()}]} : {})}), onSuccess: () => { setTopic(""); setRetentionMs(""); void queryClient.invalidateQueries({queryKey: ["msk-topics", clusterArn]}); }});
|
||||
const deleteTopic = useMutation({mutationFn: (topicName: string) => deleteMskTopic(clusterArn, topicName), onSuccess: () => { setMessages([]); void queryClient.invalidateQueries({queryKey: ["msk-topics", clusterArn]}); }});
|
||||
const produce = useMutation({mutationFn: () => produceMskMessage(clusterArn, messageTopic, {value: messageValue, ...(messageKey.trim() ? {key: messageKey} : {})}), onSuccess: () => { setMessageKey(""); setMessageValue(""); }});
|
||||
const peek = useMutation({mutationFn: () => peekMskMessages(clusterArn, messageTopic, 50), onSuccess: setMessages});
|
||||
const loadLag = useMutation({mutationFn: () => getMskConsumerGroupLag(clusterArn, groupId.trim(), messageTopic || undefined), onSuccess: setLag});
|
||||
const error = createCluster.error ?? deleteCluster.error ?? createTopic.error ?? deleteTopic.error ?? produce.error ?? peek.error ?? loadLag.error;
|
||||
|
||||
return <div className="sns-topic-workspace msk-workspace">
|
||||
<section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Amazon MSK</p><h3>Kafka clusters</h3><p className="muted compact-text">Broker access is open: no IAM, SASL, or TLS authentication is configured.</p></div><button className="button" type="button" disabled={!runtimeReachable || clustersQuery.isFetching} onClick={() => clustersQuery.refetch()}><RefreshCw size={13}/>{clustersQuery.isFetching ? "Loading" : "Refresh"}</button></div>
|
||||
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (clusterName.trim() && Number(brokers) > 0 && version.trim() && subnets.length > 0) createCluster.mutate(); }}>
|
||||
<label className="dynamic-field"><span>Cluster name</span><input className="input" value={clusterName} onChange={(event) => setClusterName(event.target.value)} required /></label><label className="dynamic-field"><span>Broker nodes</span><input className="input" type="number" min="1" value={brokers} onChange={(event) => setBrokers(event.target.value)} required /></label><label className="dynamic-field"><span>Kafka version</span><input className="input" value={version} onChange={(event) => setVersion(event.target.value)} required /></label><label className="dynamic-field"><span>Client subnets</span><select className="input" multiple value={subnets} onChange={(event) => setSubnets(Array.from(event.currentTarget.selectedOptions, (option) => option.value))} required>{subnetsQuery.data?.map((subnet) => <option key={subnet.subnetId} value={subnet.subnetId}>{subnet.subnetId} ({subnet.availabilityZone})</option>)}</select><small>Select one or more existing EC2 subnets.</small></label><button className="button primary" type="submit" disabled={!runtimeReachable || subnets.length === 0 || createCluster.isPending}>{createCluster.isPending ? <Loader2 size={13} className="spin"/> : <Plus size={13}/>}{createCluster.isPending ? "Creating" : "Create cluster"}</button>
|
||||
</form>
|
||||
<div className="apigateway-route-explorer"><label className="dynamic-field"><span>Active cluster</span><select className="input" value={clusterArn} onChange={(event) => { setClusterArn(event.target.value); setDeleteClusterConfirmation(false); }} disabled={!runtimeReachable}><option value="">Select a cluster</option>{clustersQuery.data?.map((cluster) => <option key={cluster.ClusterArn} value={cluster.ClusterArn}>{cluster.ClusterName ?? cluster.ClusterArn} {cluster.State ? `(${cluster.State})` : ""}</option>)}</select></label>{clusterArn && (deleteClusterConfirmation ? <div className="apigateway-route-confirm"><span>Delete this cluster and all topics?</span><button className="button danger compact" type="button" disabled={deleteCluster.isPending} onClick={() => deleteCluster.mutate()}>{deleteCluster.isPending ? "Deleting" : "Delete cluster"}</button><button className="button compact" type="button" disabled={deleteCluster.isPending} onClick={() => setDeleteClusterConfirmation(false)}>Cancel</button></div> : <button className="button danger compact" type="button" disabled={!runtimeReachable || deleteCluster.isPending} onClick={() => setDeleteClusterConfirmation(true)}><Trash2 size={13}/>Delete cluster</button>)}</div>
|
||||
</section>
|
||||
<section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Topics</p><h3>Kafka Admin API</h3><p className="muted compact-text">Topic management uses the cluster's Kafka Admin API. Retention configuration is accepted, but enforcement is unverified.</p></div></div>
|
||||
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (clusterArn && topic.trim() && Number(partitions) > 0) createTopic.mutate(); }}><label className="dynamic-field"><span>Topic</span><input className="input" value={topic} onChange={(event) => setTopic(event.target.value)} required /></label><label className="dynamic-field"><span>Partitions</span><input className="input" type="number" min="1" value={partitions} onChange={(event) => setPartitions(event.target.value)} required /></label><label className="dynamic-field"><span>Retention (ms, optional)</span><input className="input" type="number" min="0" value={retentionMs} onChange={(event) => setRetentionMs(event.target.value)} /><small>Configured but enforcement unverified.</small></label><button className="button primary" type="submit" disabled={!runtimeReachable || !clusterArn || createTopic.isPending}>{createTopic.isPending ? "Creating" : "Create topic"}</button></form>
|
||||
{topicsQuery.isLoading && <p className="muted compact-text">Loading topics...</p>}{topicsQuery.data?.length === 0 && <p className="muted compact-text">No topics found for this cluster.</p>}{topicsQuery.data?.map((item) => <div className="apigateway-route-item" key={item.name}><div className="apigateway-route-select"><code>{item.name}</code><small>{item.partitionCount} partition{item.partitionCount === 1 ? "" : "s"}</small></div><button className="icon-btn danger" type="button" aria-label={`Delete topic ${item.name}`} disabled={deleteTopic.isPending} onClick={() => deleteTopic.mutate(item.name)}><Trash2 size={14}/></button></div>)}
|
||||
</section>
|
||||
<section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Messages</p><h3>Produce and peek</h3><p className="muted compact-text">Peek uses a non-committing consumer and reads from the beginning.</p></div></div>
|
||||
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (clusterArn && messageTopic && messageValue) produce.mutate(); }}><label className="dynamic-field"><span>Topic</span><select className="input" value={messageTopic} onChange={(event) => setMessageTopic(event.target.value)} required><option value="">Select a topic</option>{topicsQuery.data?.map((item) => <option key={item.name} value={item.name}>{item.name}</option>)}</select></label><label className="dynamic-field"><span>Key (optional)</span><input className="input" value={messageKey} onChange={(event) => setMessageKey(event.target.value)} /></label><label className="dynamic-field dynamic-field--span"><span>Value</span><textarea className="textarea code-textarea small" value={messageValue} onChange={(event) => setMessageValue(event.target.value)} required /></label><button className="button primary" type="submit" disabled={!runtimeReachable || !clusterArn || !messageTopic || !messageValue || produce.isPending}>{produce.isPending ? <Loader2 size={13} className="spin"/> : <Send size={13}/>}{produce.isPending ? "Producing" : "Produce message"}</button><button className="button" type="button" disabled={!runtimeReachable || !clusterArn || !messageTopic || peek.isPending} onClick={() => peek.mutate()}>{peek.isPending ? "Peeking" : "Peek messages"}</button></form>
|
||||
{messages.map((message) => <article className="apigateway-route-item" key={`${message.partition}:${message.offset}`}><div className="apigateway-route-select"><pre>{message.value ?? ""}</pre><small>Partition {message.partition} · Offset {message.offset} · Timestamp {formatTimestamp(message.timestamp)}</small>{message.key && <small>Key: <code>{message.key}</code></small>}</div></article>)}{messages.length === 0 && <p className="muted compact-text">Select a topic and peek to inspect up to 50 messages.</p>}
|
||||
</section>
|
||||
<section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Consumer groups</p><h3>Lag</h3></div></div><form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (clusterArn && groupId.trim()) loadLag.mutate(); }}><label className="dynamic-field"><span>Consumer group ID</span><input className="input" value={groupId} onChange={(event) => setGroupId(event.target.value)} required /></label><button className="button" type="submit" disabled={!runtimeReachable || !clusterArn || !groupId.trim() || loadLag.isPending}>{loadLag.isPending ? "Loading" : "View lag"}</button></form>{lag.map((topicLag) => <div className="apigateway-route-item" key={topicLag.topic}><div className="apigateway-route-select"><code>{topicLag.topic}</code><small>Total lag: {topicLag.totalLag}</small>{topicLag.partitions.map((partition) => <small key={partition.partition}>Partition {partition.partition}: {partition.currentOffset} / {partition.endOffset}, lag {partition.lag}</small>)}</div></div>)}{error instanceof Error && <p className="error-text compact-text">{error.message}</p>}
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: string): string { const date = new Date(Number(timestamp)); return Number.isNaN(date.getTime()) ? timestamp : date.toLocaleString(); }
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
allocateEc2ElasticIp,
|
||||
associateEc2ElasticIp,
|
||||
attachEc2InternetGateway,
|
||||
authorizeEc2SecurityGroupEgress,
|
||||
authorizeEc2SecurityGroupIngress,
|
||||
createEc2InternetGateway,
|
||||
createEc2KeyPair,
|
||||
createEc2Route,
|
||||
@@ -39,8 +37,6 @@ import {
|
||||
modifyEc2SubnetAttribute,
|
||||
modifyEc2VpcAttribute,
|
||||
releaseEc2ElasticIp,
|
||||
revokeEc2SecurityGroupEgress,
|
||||
revokeEc2SecurityGroupIngress,
|
||||
type CreateRouteInput,
|
||||
type Ec2ElasticIp,
|
||||
type Ec2InternetGateway,
|
||||
@@ -55,8 +51,9 @@ import {
|
||||
type VpcWizardInput,
|
||||
type VpcWizardResult,
|
||||
} from '@/api/aws/ec2.api'
|
||||
import {deleteFlatItem, listFlatItems, putFlatItem} from '@/api/cloudProxyClient'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
import type {ChildItem, CloudResource} from '@/types/resource'
|
||||
import {calculateSubnetCidrs, isValidCidr, isValidPort} from '@/lib/network'
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
@@ -133,20 +130,12 @@ function nameTag(tags: Array<{key: string; value: string}>): string {
|
||||
return tags.find((t) => t.key === 'Name')?.value ?? ''
|
||||
}
|
||||
|
||||
function permissionsToRules(perms: Ec2IpPermission[]): EditRule[] {
|
||||
const rules: EditRule[] = []
|
||||
for (const perm of perms) {
|
||||
for (const cidr of perm.ipRanges) {
|
||||
rules.push({
|
||||
rid: crypto.randomUUID(),
|
||||
protocol: perm.protocol,
|
||||
fromPort: perm.fromPort !== null ? String(perm.fromPort) : (perm.protocol === 'icmp' ? '-1' : '0'),
|
||||
toPort: perm.toPort !== null ? String(perm.toPort) : (perm.protocol === 'icmp' ? '-1' : '0'),
|
||||
cidr,
|
||||
})
|
||||
}
|
||||
}
|
||||
return rules
|
||||
function ingressItemsToRules(items: ChildItem[]): EditRule[] {
|
||||
return items.flatMap((item) => {
|
||||
const {protocol, fromPort, toPort, cidr} = item.body
|
||||
if (typeof protocol !== 'string' || typeof fromPort !== 'number' || typeof toPort !== 'number' || typeof cidr !== 'string') return []
|
||||
return [{rid: item.id, protocol, fromPort: String(fromPort), toPort: String(toPort), cidr}]
|
||||
})
|
||||
}
|
||||
|
||||
function ruleToPermission(r: EditRule) {
|
||||
@@ -375,30 +364,33 @@ function CreateSgModal({vpcs, onClose}: {vpcs: Ec2Vpc[]; onClose: () => void}) {
|
||||
|
||||
function EditSgRulesModal({sg, onClose}: {sg: Ec2SecurityGroup; onClose: () => void}) {
|
||||
const qc = useQueryClient()
|
||||
const [inbound, setInbound] = useState<EditRule[]>(() => permissionsToRules(sg.inboundRules))
|
||||
const [outbound, setOutbound] = useState<EditRule[]>(() => permissionsToRules(sg.outboundRules))
|
||||
const queryKey = ['cloud-items', 'aws', 'networking', sg.groupId]
|
||||
const ingressQ = useQuery({
|
||||
queryKey,
|
||||
queryFn: ({signal}) => listFlatItems('aws', 'networking', sg.groupId, undefined, signal),
|
||||
})
|
||||
const [inbound, setInbound] = useState<EditRule[]>([])
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (ingressQ.data) setInbound(ingressItemsToRules(ingressQ.data.items))
|
||||
}, [ingressQ.data])
|
||||
|
||||
const mut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const origInKeys = new Set(permissionsToRules(sg.inboundRules).map(ruleKey))
|
||||
const original = ingressItemsToRules(ingressQ.data?.items ?? [])
|
||||
const origInKeys = new Set(original.map(ruleKey))
|
||||
const newInKeys = new Set(inbound.map(ruleKey))
|
||||
const toAddIn = inbound.filter((r) => !origInKeys.has(ruleKey(r)))
|
||||
const toRevokeIn = permissionsToRules(sg.inboundRules).filter((r) => !newInKeys.has(ruleKey(r)))
|
||||
|
||||
const origOutKeys = new Set(permissionsToRules(sg.outboundRules).map(ruleKey))
|
||||
const newOutKeys = new Set(outbound.map(ruleKey))
|
||||
const toAddOut = outbound.filter((r) => !origOutKeys.has(ruleKey(r)))
|
||||
const toRevokeOut = permissionsToRules(sg.outboundRules).filter((r) => !newOutKeys.has(ruleKey(r)))
|
||||
const toRevokeIn = original.filter((r) => !newInKeys.has(ruleKey(r)))
|
||||
|
||||
await Promise.all([
|
||||
...toAddIn.map((r) => authorizeEc2SecurityGroupIngress(sg.groupId, ruleToPermission(r))),
|
||||
...toRevokeIn.map((r) => revokeEc2SecurityGroupIngress(sg.groupId, ruleToPermission(r))),
|
||||
...toAddOut.map((r) => authorizeEc2SecurityGroupEgress(sg.groupId, ruleToPermission(r))),
|
||||
...toRevokeOut.map((r) => revokeEc2SecurityGroupEgress(sg.groupId, ruleToPermission(r))),
|
||||
...toAddIn.map((r) => putFlatItem('aws', 'networking', sg.groupId, ruleToPermission(r))),
|
||||
...toRevokeIn.map((r) => deleteFlatItem('aws', 'networking', sg.groupId, r.rid)),
|
||||
])
|
||||
},
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({queryKey})
|
||||
void qc.invalidateQueries({queryKey: ['ec2', 'security-groups']})
|
||||
onClose()
|
||||
},
|
||||
@@ -410,23 +402,25 @@ function EditSgRulesModal({sg, onClose}: {sg: Ec2SecurityGroup; onClose: () => v
|
||||
<div className="create-table-modal sg-rules-edit-modal">
|
||||
<div className="modal-header" style={{display: 'flex', alignItems: 'center', gap: 8}}>
|
||||
<Shield size={14} style={{color: 'var(--accent)'}}/>
|
||||
<span>Edit rules — {sg.groupName}</span>
|
||||
<span>Edit inbound rules — {sg.groupName}</span>
|
||||
<button className="icon-btn" style={{marginLeft: 'auto'}} onClick={onClose} aria-label="Close" title="Close"><X size={13} aria-hidden="true"/></button>
|
||||
</div>
|
||||
<div className="modal-section">
|
||||
<p className="modal-section-title" style={{marginBottom: 8}}>Inbound rules</p>
|
||||
<EditRuleTable rules={inbound} onChange={setInbound}/>
|
||||
</div>
|
||||
<div className="modal-section">
|
||||
<p className="modal-section-title" style={{marginBottom: 8}}>Outbound rules</p>
|
||||
<EditRuleTable rules={outbound} onChange={setOutbound}/>
|
||||
<p className="modal-section-title" style={{marginBottom: 8}}>Ingress rules</p>
|
||||
{ingressQ.isLoading ? (
|
||||
<p className="muted" style={{fontSize: 12}}>Loading ingress rules...</p>
|
||||
) : ingressQ.isError ? (
|
||||
<p style={{fontSize: 12, color: 'var(--status-error)'}}>{ingressQ.error instanceof Error ? ingressQ.error.message : 'Unable to load ingress rules'}</p>
|
||||
) : (
|
||||
<EditRuleTable rules={inbound} onChange={setInbound}/>
|
||||
)}
|
||||
</div>
|
||||
{err && <p style={{fontSize: 12, color: 'var(--status-error)', margin: '0 0 8px'}}>{err}</p>}
|
||||
<div className="modal-footer">
|
||||
<button className="button" onClick={onClose} disabled={mut.isPending}>Cancel</button>
|
||||
<button className="button primary" disabled={mut.isPending} onClick={() => mut.mutate()}>
|
||||
<button className="button primary" disabled={ingressQ.isLoading || ingressQ.isError || mut.isPending} onClick={() => mut.mutate()}>
|
||||
{mut.isPending ? <Loader2 size={13}/> : null}
|
||||
Save rules
|
||||
Save ingress rules
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -977,7 +971,7 @@ function SgDetail({sg, vpcs, onEdit, onDelete}: {
|
||||
<Shield size={14} style={{color: 'var(--accent)'}}/>
|
||||
<h3 style={{marginLeft: 6}}>{sg.groupName}</h3>
|
||||
<div style={{marginLeft: 'auto', display: 'flex', gap: 6}}>
|
||||
<button className="button compact" onClick={onEdit}>Edit rules</button>
|
||||
<button className="button compact" onClick={onEdit}>Edit inbound rules</button>
|
||||
<button className="button compact danger" onClick={onDelete}><Trash2 size={12}/> Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import {expect, test} from 'vitest'
|
||||
import {htmlMimePart, inlineMimeImages, parseMimeAttachments} from '@/lib/emailMime'
|
||||
|
||||
test('reads MIME filename, content type, disposition, and content ID', () => {
|
||||
const attachments = parseMimeAttachments('Content-Type: multipart/mixed; boundary="part"\r\n\r\n--part\r\nContent-Type: image/png; name="chart.png"\r\nContent-Disposition: inline; filename="chart.png"\r\nContent-ID: <chart-1>\r\nContent-Transfer-Encoding: base64\r\n\r\naGVsbG8=\r\n--part--\r\n')
|
||||
expect(attachments).toEqual([{filename: 'chart.png', contentType: 'image/png', disposition: 'inline', contentId: 'chart-1'}])
|
||||
})
|
||||
|
||||
test('maps base64 inline image parts to their Content-ID', () => {
|
||||
const images = inlineMimeImages('Content-Type: multipart/related; boundary="part"\r\n\r\n--part\r\nContent-Type: image/png\r\nContent-ID: <chart-1>\r\nContent-Transfer-Encoding: base64\r\n\r\naGVsbG8=\r\n--part--\r\n')
|
||||
expect(images).toEqual({'chart-1': 'data:image/png;base64,aGVsbG8='})
|
||||
})
|
||||
|
||||
test('extracts the HTML part from a raw multipart message', () => {
|
||||
expect(htmlMimePart('Content-Type: multipart/related; boundary="part"\r\n\r\n--part\r\nContent-Type: text/html\r\n\r\n<img src="cid:chart-1">\r\n--part--\r\n')).toContain('cid:chart-1')
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import {formatBytes} from "@/lib/format";
|
||||
import {CopyValueButton} from "@/components/CopyValueButton";
|
||||
import {rebootCloudResource, startCloudResource, stopCloudResource} from "@/api/cloudProxyClient";
|
||||
import {cloudQueryKeys} from "@/api/queries/cloudQueries";
|
||||
import {decodeRawMime, htmlMimePart, inlineMimeImages, parseMimeAttachments} from '@/lib/emailMime'
|
||||
|
||||
interface ResourceInspectorProps {
|
||||
resource?: CloudResource;
|
||||
@@ -217,7 +218,12 @@ function EmailInspector({resource}: {resource: CloudResource}) {
|
||||
const textBody = getStringMetadata(resource.metadata.textBody)
|
||||
const htmlBody = getStringMetadata(resource.metadata.htmlBody)
|
||||
const rawData = getStringMetadata(resource.metadata.rawData)
|
||||
const hasPreview = Boolean(htmlBody || textBody)
|
||||
const rawMime = rawData ? decodeRawMime(rawData) : null
|
||||
const attachments = rawMime ? parseMimeAttachments(rawMime) : []
|
||||
const inlineImages = rawMime ? inlineMimeImages(rawMime) : {}
|
||||
const rawHtmlBody = rawMime ? htmlMimePart(rawMime) : null
|
||||
const previewHtml = htmlBody ?? rawHtmlBody
|
||||
const hasPreview = Boolean(previewHtml || textBody)
|
||||
const activeTab = tab === 'preview' && !hasPreview ? (rawData ? 'raw' : 'text') : tab
|
||||
|
||||
return (
|
||||
@@ -235,17 +241,18 @@ function EmailInspector({resource}: {resource: CloudResource}) {
|
||||
<InspectorItem label="Captured At" value={resource.createdAt ?? '-'} />
|
||||
</div>
|
||||
<section className="inspector-section">
|
||||
<div className="drawer-tabs" style={{marginBottom: 10}}>
|
||||
<div className="drawer-tabs" style={{marginBottom: 10}}>
|
||||
<button className={`drawer-tab ${activeTab === 'preview' ? 'active' : ''}`} disabled={!hasPreview} onClick={() => setTab('preview')}>Preview</button>
|
||||
<button className={`drawer-tab ${activeTab === 'text' ? 'active' : ''}`} disabled={!textBody} onClick={() => setTab('text')}>Text</button>
|
||||
<button className={`drawer-tab ${activeTab === 'raw' ? 'active' : ''}`} disabled={!rawData} onClick={() => setTab('raw')}>Raw</button>
|
||||
<button className={`drawer-tab ${activeTab === 'raw' ? 'active' : ''}`} disabled={!rawData} onClick={() => setTab('raw')}>Raw</button>
|
||||
{rawMime && <button className="button compact" type="button" onClick={() => downloadRawEmail(resource.id, rawMime)}>Download MIME</button>}
|
||||
</div>
|
||||
{activeTab === 'preview' && htmlBody ? (
|
||||
{activeTab === 'preview' && previewHtml ? (
|
||||
<iframe
|
||||
title="Email HTML preview"
|
||||
sandbox=""
|
||||
referrerPolicy="no-referrer"
|
||||
srcDoc={safeEmailDocument(htmlBody)}
|
||||
srcDoc={safeEmailDocument(previewHtml, inlineImages)}
|
||||
style={{width: '100%', minHeight: 260, border: '1px solid var(--border)', borderRadius: 4, background: '#fff'}}
|
||||
/>
|
||||
) : activeTab === 'preview' ? (
|
||||
@@ -253,15 +260,32 @@ function EmailInspector({resource}: {resource: CloudResource}) {
|
||||
) : activeTab === 'text' ? (
|
||||
<pre className="metadata-block" style={{whiteSpace: 'pre-wrap', margin: 0}}>{textBody ?? 'No text body captured.'}</pre>
|
||||
) : (
|
||||
<pre className="metadata-block" style={{whiteSpace: 'pre-wrap', wordBreak: 'break-all', margin: 0}}>{rawData ?? 'No raw MIME data captured.'}</pre>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
<pre className="metadata-block" style={{whiteSpace: 'pre-wrap', wordBreak: 'break-all', margin: 0}}>{rawMime ?? 'No raw MIME data captured.'}</pre>
|
||||
)}
|
||||
</section>
|
||||
{attachments.length > 0 && <section className="inspector-section">
|
||||
<p className="metric-label">MIME attachments</p>
|
||||
{attachments.map((attachment, index) => <div className="apigateway-route-item" key={`${attachment.filename}-${index}`}>
|
||||
<div><code>{attachment.filename ?? '(unnamed MIME part)'}</code><small>{attachment.contentType} · {attachment.disposition}</small>{attachment.contentId && <small>Content-ID: {attachment.contentId} ({attachment.disposition === 'inline' ? 'inline' : 'referenced part'})</small>}</div>
|
||||
</div>)}
|
||||
</section>}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function safeEmailDocument(html: string): string {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'"></head><body>${html}</body></html>`
|
||||
function downloadRawEmail(id: string, rawData: string): void {
|
||||
const url = URL.createObjectURL(new Blob([rawData], {type: 'message/rfc822'}))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${id || 'message'}.eml`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
|
||||
function safeEmailDocument(html: string, inlineImages: Record<string, string> = {}): string {
|
||||
const withInlineImages = html.replace(/cid:([^"'\s>]+)/gi, (reference, contentId: string) => inlineImages[contentId] ?? reference)
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'"></head><body>${withInlineImages}</body></html>`
|
||||
}
|
||||
|
||||
function ProviderDatabaseSection({ cloud }: { cloud: string }) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen, waitFor} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {SesConsolePanel} from './SesConsolePanel'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
createSesConfigurationSet: vi.fn(), createSesEventDestination: vi.fn(), createSesIdentity: vi.fn(), deleteSesConfigurationSet: vi.fn(), deleteSesEventDestination: vi.fn(), deleteSesIdentity: vi.fn(), deleteSesSuppression: vi.fn(), deleteSesTemplate: vi.fn(), getSesTemplate: vi.fn(), listSesConfigurationSets: vi.fn(), listSesEventBuses: vi.fn(), listSesEventDestinations: vi.fn(), listSesIdentities: vi.fn(), listSesSuppressions: vi.fn(), listSesTemplates: vi.fn(), listSesTopics: vi.fn(), putSesSuppression: vi.fn(), renderSesTemplate: vi.fn(), saveSesTemplate: vi.fn(), sendSesRaw: vi.fn(), sendSesSimple: vi.fn(), sendSesTemplate: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/api/sesClient', () => api)
|
||||
|
||||
function renderPanel() { return render(<QueryClientProvider client={new QueryClient({defaultOptions: {queries: {retry: false}}})}><SesConsolePanel runtimeReachable/></QueryClientProvider>) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.listSesIdentities.mockResolvedValue([]); api.listSesTemplates.mockResolvedValue([]); api.listSesSuppressions.mockResolvedValue([]); api.listSesConfigurationSets.mockResolvedValue([]); api.listSesTopics.mockResolvedValue([]); api.listSesEventBuses.mockResolvedValue([])
|
||||
})
|
||||
|
||||
test('sends to the documented simulator address through the simple SES API', async () => {
|
||||
api.sendSesSimple.mockResolvedValue(undefined)
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
await user.type(screen.getByLabelText('From'), 'sender@example.test')
|
||||
await user.click(screen.getByRole('button', {name: 'Send email'}))
|
||||
await waitFor(() => expect(api.sendSesSimple).toHaveBeenCalledWith(expect.objectContaining({FromEmailAddress: 'sender@example.test', Destination: {ToAddresses: ['success@simulator.amazonses.com']}})))
|
||||
})
|
||||
|
||||
test('states the local identity and suppression behavior plainly', () => {
|
||||
renderPanel()
|
||||
expect(screen.getByText(/not sandbox-gated/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/accepted and captured/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('bounce')).toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {Loader2, Mail, Plus, RefreshCw, Send, Trash2} from 'lucide-react'
|
||||
import {createSesConfigurationSet, createSesEventDestination, createSesIdentity, deleteSesConfigurationSet, deleteSesEventDestination, deleteSesIdentity, deleteSesSuppression, deleteSesTemplate, getSesTemplate, listSesConfigurationSets, listSesEventBuses, listSesEventDestinations, listSesIdentities, listSesSuppressions, listSesTemplates, listSesTopics, putSesSuppression, renderSesTemplate, saveSesTemplate, sendSesRaw, sendSesSimple, sendSesTemplate} from '@/api/sesClient'
|
||||
|
||||
const SIMULATORS = ['success@simulator.amazonses.com', 'bounce@simulator.amazonses.com', 'complaint@simulator.amazonses.com']
|
||||
const EVENT_TYPES = ['SEND', 'DELIVERY', 'BOUNCE', 'COMPLAINT', 'REJECT']
|
||||
|
||||
export function SesConsolePanel({runtimeReachable}: {runtimeReachable: boolean}) {
|
||||
const qc = useQueryClient()
|
||||
const [identity, setIdentity] = useState('')
|
||||
const [mode, setMode] = useState<'simple' | 'raw' | 'template'>('simple')
|
||||
const [from, setFrom] = useState('')
|
||||
const [to, setTo] = useState(SIMULATORS[0])
|
||||
const [subject, setSubject] = useState('Local SES test')
|
||||
const [text, setText] = useState('Sent from the Floci SES console.')
|
||||
const [html, setHtml] = useState('')
|
||||
const [raw, setRaw] = useState('From: sender@example.test\r\nTo: success@simulator.amazonses.com\r\nSubject: Raw SES test\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nHello from a raw MIME message.')
|
||||
const [configurationSet, setConfigurationSet] = useState('')
|
||||
const [templateName, setTemplateName] = useState('')
|
||||
const [templateData, setTemplateData] = useState('{"name":"Floci"}')
|
||||
const [editingTemplate, setEditingTemplate] = useState('')
|
||||
const [templateSubject, setTemplateSubject] = useState('Hello {{name}}')
|
||||
const [templateText, setTemplateText] = useState('Hello {{name}}')
|
||||
const [templateHtml, setTemplateHtml] = useState('')
|
||||
const [rendered, setRendered] = useState('')
|
||||
const [suppressionAddress, setSuppressionAddress] = useState('')
|
||||
const [suppressionReason, setSuppressionReason] = useState<'BOUNCE' | 'COMPLAINT'>('BOUNCE')
|
||||
const [configurationSetName, setConfigurationSetName] = useState('')
|
||||
const [selectedConfigurationSet, setSelectedConfigurationSet] = useState('')
|
||||
const [destinationName, setDestinationName] = useState('')
|
||||
const [destinationKind, setDestinationKind] = useState<'sns' | 'eventbridge' | 'cloudwatch'>('sns')
|
||||
const [destinationTarget, setDestinationTarget] = useState('')
|
||||
const [events, setEvents] = useState<string[]>(['SEND', 'BOUNCE', 'DELIVERY'])
|
||||
const [dimensionName, setDimensionName] = useState('ses:configuration-set')
|
||||
|
||||
const identities = useQuery({queryKey: ['ses-identities'], queryFn: listSesIdentities, enabled: runtimeReachable})
|
||||
const templates = useQuery({queryKey: ['ses-templates'], queryFn: listSesTemplates, enabled: runtimeReachable})
|
||||
const suppressions = useQuery({queryKey: ['ses-suppressions'], queryFn: listSesSuppressions, enabled: runtimeReachable})
|
||||
const configurationSets = useQuery({queryKey: ['ses-configuration-sets'], queryFn: listSesConfigurationSets, enabled: runtimeReachable})
|
||||
const topics = useQuery({queryKey: ['ses-topics'], queryFn: listSesTopics, enabled: runtimeReachable})
|
||||
const buses = useQuery({queryKey: ['ses-event-buses'], queryFn: listSesEventBuses, enabled: runtimeReachable})
|
||||
const destinations = useQuery({queryKey: ['ses-event-destinations', selectedConfigurationSet], queryFn: () => listSesEventDestinations(selectedConfigurationSet), enabled: runtimeReachable && Boolean(selectedConfigurationSet)})
|
||||
const refreshMailbox = () => qc.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'email']})
|
||||
const send = useMutation({mutationFn: async () => {
|
||||
const destination = {ToAddresses: to.split(',').map((value) => value.trim()).filter(Boolean)}
|
||||
const options = configurationSet ? {ConfigurationSetName: configurationSet} : {}
|
||||
if (mode === 'raw') return sendSesRaw({...options, FromEmailAddress: from || undefined, Destination: destination, Content: {Raw: {Data: btoa(unescape(encodeURIComponent(raw)))}}})
|
||||
if (mode === 'template') return sendSesTemplate({...options, FromEmailAddress: from, Destination: destination, Content: {Template: {TemplateName: templateName, TemplateData: templateData}}})
|
||||
return sendSesSimple({...options, FromEmailAddress: from, Destination: destination, Content: {Simple: {Subject: {Data: subject}, Body: {...(text ? {Text: {Data: text}} : {}), ...(html ? {Html: {Data: html}} : {})}}}})
|
||||
}, onSuccess: () => { refreshMailbox(); setTimeout(refreshMailbox, 300) }})
|
||||
const createIdentity = useMutation({mutationFn: () => createSesIdentity(identity.trim()), onSuccess: () => {setIdentity(''); void qc.invalidateQueries({queryKey: ['ses-identities']})}})
|
||||
const saveTemplate = useMutation({mutationFn: () => saveSesTemplate(editingTemplate.trim(), {Subject: templateSubject, ...(templateText ? {Text: templateText} : {}), ...(templateHtml ? {Html: templateHtml} : {})}, templates.data?.some((item) => item.TemplateName === editingTemplate) ?? false), onSuccess: () => void qc.invalidateQueries({queryKey: ['ses-templates']})})
|
||||
const renderTemplate = useMutation({mutationFn: () => renderSesTemplate(editingTemplate || templateName, templateData), onSuccess: setRendered})
|
||||
const addSuppression = useMutation({mutationFn: () => putSesSuppression(suppressionAddress.trim(), suppressionReason), onSuccess: () => {setSuppressionAddress(''); void qc.invalidateQueries({queryKey: ['ses-suppressions']})}})
|
||||
const createConfigurationSet = useMutation({mutationFn: () => createSesConfigurationSet(configurationSetName.trim()), onSuccess: () => {setConfigurationSetName(''); void qc.invalidateQueries({queryKey: ['ses-configuration-sets']})}})
|
||||
const addDestination = useMutation({mutationFn: () => createSesEventDestination(selectedConfigurationSet, {EventDestinationName: destinationName.trim(), EventDestination: {Enabled: true, MatchingEventTypes: events, ...(destinationKind === 'sns' ? {SnsDestination: {TopicArn: destinationTarget}} : destinationKind === 'eventbridge' ? {EventBridgeDestination: {EventBusArn: destinationTarget}} : {CloudWatchDestination: {DimensionConfigurations: [{DimensionName: dimensionName, DimensionValueSource: 'MESSAGE_TAG', DefaultDimensionValue: 'local'}]}})}}), onSuccess: () => {setDestinationName(''); void qc.invalidateQueries({queryKey: ['ses-event-destinations', selectedConfigurationSet]})}})
|
||||
const error = send.error ?? createIdentity.error ?? saveTemplate.error ?? renderTemplate.error ?? addSuppression.error ?? createConfigurationSet.error ?? addDestination.error
|
||||
useEffect(() => { if (!selectedConfigurationSet && configurationSets.data?.[0]?.ConfigurationSetName) setSelectedConfigurationSet(configurationSets.data[0].ConfigurationSetName) }, [configurationSets.data, selectedConfigurationSet])
|
||||
async function loadTemplate(name: string) { setEditingTemplate(name); const detail = await getSesTemplate(name); setTemplateSubject(detail.TemplateContent?.Subject ?? ''); setTemplateText(detail.TemplateContent?.Text ?? ''); setTemplateHtml(detail.TemplateContent?.Html ?? '') }
|
||||
const pending = send.isPending || createIdentity.isPending || saveTemplate.isPending || addSuppression.isPending || createConfigurationSet.isPending || addDestination.isPending
|
||||
|
||||
return <div className="ses-console">
|
||||
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">SES identities</p><h3>Verify senders</h3><p className="muted compact-text">Floci verifies identities immediately for AWS-compatible workflows. Local sending is not sandbox-gated.</p></div><button className="button compact" type="button" onClick={() => identities.refetch()}><RefreshCw size={13}/>Refresh</button></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); if (identity.trim()) createIdentity.mutate()}}><input className="input" value={identity} onChange={(event) => setIdentity(event.target.value)} placeholder="sender@example.test or example.test" required/><button className="button primary" disabled={!runtimeReachable || createIdentity.isPending}><Plus size={13}/>Verify identity</button></form>{identities.data?.map((item) => <div className="apigateway-route-item" key={item.IdentityName}><div><code>{item.IdentityName}</code><small>{item.IdentityType} · {item.VerifiedForSendingStatus ? 'verified for sending' : 'not verified'}</small></div><button className="icon-btn danger" onClick={() => deleteSesIdentity(item.IdentityName).then(() => qc.invalidateQueries({queryKey: ['ses-identities']}))}><Trash2 size={13}/></button></div>)}</section>
|
||||
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Compose</p><h3><Mail size={15}/> Send through SES</h3><p className="muted compact-text">Simulator recipients produce documented delivery, bounce, and complaint events locally.</p></div></div><div className="button-row">{SIMULATORS.map((address) => <button className="button compact" type="button" key={address} onClick={() => setTo(address)}>{address.split('@')[0]}</button>)}</div><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); send.mutate()}}><label className="dynamic-field"><span>Format</span><select className="input" value={mode} onChange={(event) => setMode(event.target.value as typeof mode)}><option value="simple">Simple</option><option value="raw">Raw MIME</option><option value="template">Template</option></select></label><label className="dynamic-field"><span>From</span><input className="input" value={from} onChange={(event) => setFrom(event.target.value)} placeholder="Verified identity" required={mode !== 'raw'}/></label><label className="dynamic-field"><span>To</span><input className="input" value={to} onChange={(event) => setTo(event.target.value)} required/></label><label className="dynamic-field"><span>Configuration set</span><select className="input" value={configurationSet} onChange={(event) => setConfigurationSet(event.target.value)}><option value="">None</option>{configurationSets.data?.map((item) => <option key={item.ConfigurationSetName} value={item.ConfigurationSetName}>{item.ConfigurationSetName}</option>)}</select></label>{mode === 'simple' && <><label className="dynamic-field dynamic-field--span"><span>Subject</span><input className="input" value={subject} onChange={(event) => setSubject(event.target.value)} required/></label><label className="dynamic-field"><span>Text body</span><textarea className="textarea code-textarea small" value={text} onChange={(event) => setText(event.target.value)}/></label><label className="dynamic-field"><span>HTML body</span><textarea className="textarea code-textarea small" value={html} onChange={(event) => setHtml(event.target.value)}/></label></>}{mode === 'raw' && <label className="dynamic-field dynamic-field--span"><span>Raw MIME</span><textarea className="textarea code-textarea" value={raw} onChange={(event) => setRaw(event.target.value)} required/></label>}{mode === 'template' && <><label className="dynamic-field"><span>Template</span><select className="input" value={templateName} onChange={(event) => setTemplateName(event.target.value)} required><option value="">Select template</option>{templates.data?.map((item) => <option key={item.TemplateName} value={item.TemplateName}>{item.TemplateName}</option>)}</select></label><label className="dynamic-field"><span>Template data (JSON)</span><textarea className="textarea code-textarea small" value={templateData} onChange={(event) => setTemplateData(event.target.value)} required/></label></>}<button className="button primary" disabled={!runtimeReachable || pending}>{send.isPending ? <Loader2 className="spin" size={13}/> : <Send size={13}/>}{send.isPending ? 'Sending' : 'Send email'}</button></form>{send.isSuccess && <p className="success-text compact-text">Accepted by SES. Refresh the mailbox below to inspect the captured message.</p>}</section>
|
||||
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Templates</p><h3>Author and render</h3></div></div><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); saveTemplate.mutate()}}><label className="dynamic-field"><span>Template name</span><input className="input" value={editingTemplate} onChange={(event) => setEditingTemplate(event.target.value)} required/></label><label className="dynamic-field"><span>Subject</span><input className="input" value={templateSubject} onChange={(event) => setTemplateSubject(event.target.value)} required/></label><label className="dynamic-field"><span>Text</span><textarea className="textarea code-textarea small" value={templateText} onChange={(event) => setTemplateText(event.target.value)}/></label><label className="dynamic-field"><span>HTML</span><textarea className="textarea code-textarea small" value={templateHtml} onChange={(event) => setTemplateHtml(event.target.value)}/></label><div className="button-row"><button className="button primary" disabled={!runtimeReachable || !editingTemplate.trim() || saveTemplate.isPending}>Save template</button><button className="button" type="button" disabled={!editingTemplate.trim() || renderTemplate.isPending} onClick={() => renderTemplate.mutate()}>Test render</button></div></form>{rendered && <pre className="metadata-block">{rendered}</pre>}{templates.data?.map((item) => <div className="apigateway-route-item" key={item.TemplateName}><code>{item.TemplateName}</code><div><button className="button compact" onClick={() => void loadTemplate(item.TemplateName)}>Edit</button><button className="icon-btn danger" onClick={() => deleteSesTemplate(item.TemplateName).then(() => qc.invalidateQueries({queryKey: ['ses-templates']}))}><Trash2 size={13}/></button></div></div>)}</section>
|
||||
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Suppression list</p><h3>Accepted but suppressed</h3><p className="muted compact-text">Suppressed mail is still accepted and captured, but it is not relayed.</p></div></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); addSuppression.mutate()}}><input className="input" type="email" value={suppressionAddress} onChange={(event) => setSuppressionAddress(event.target.value)} placeholder="recipient@example.test" required/><select className="input" value={suppressionReason} onChange={(event) => setSuppressionReason(event.target.value as typeof suppressionReason)}><option value="BOUNCE">Bounce</option><option value="COMPLAINT">Complaint</option></select><button className="button primary" disabled={addSuppression.isPending}>Add</button></form>{suppressions.data?.map((item) => <div className="apigateway-route-item" key={item.EmailAddress}><div><code>{item.EmailAddress}</code><small>{item.Reason}</small></div><button className="icon-btn danger" onClick={() => deleteSesSuppression(item.EmailAddress ?? '').then(() => qc.invalidateQueries({queryKey: ['ses-suppressions']}))}><Trash2 size={13}/></button></div>)}</section>
|
||||
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Configuration sets</p><h3>Publish SES events</h3><p className="muted compact-text">Send, delivery, bounce, and complaint events use real SNS, EventBridge, and CloudWatch destinations.</p></div></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); createConfigurationSet.mutate()}}><input className="input" value={configurationSetName} onChange={(event) => setConfigurationSetName(event.target.value)} placeholder="Configuration set name" required/><button className="button primary" disabled={createConfigurationSet.isPending}>Create</button></form><label className="dynamic-field"><span>Active configuration set</span><select className="input" value={selectedConfigurationSet} onChange={(event) => setSelectedConfigurationSet(event.target.value)}><option value="">Select configuration set</option>{configurationSets.data?.map((item) => <option key={item.ConfigurationSetName} value={item.ConfigurationSetName}>{item.ConfigurationSetName}</option>)}</select></label>{selectedConfigurationSet && <><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); addDestination.mutate()}}><label className="dynamic-field"><span>Destination name</span><input className="input" value={destinationName} onChange={(event) => setDestinationName(event.target.value)} required/></label><label className="dynamic-field"><span>Target type</span><select className="input" value={destinationKind} onChange={(event) => {setDestinationKind(event.target.value as typeof destinationKind); setDestinationTarget('')}}><option value="sns">SNS topic</option><option value="eventbridge">EventBridge bus</option><option value="cloudwatch">CloudWatch metric</option></select></label>{destinationKind !== 'cloudwatch' ? <label className="dynamic-field dynamic-field--span"><span>{destinationKind === 'sns' ? 'SNS topic' : 'Event bus'}</span><select className="input" value={destinationTarget} onChange={(event) => setDestinationTarget(event.target.value)} required><option value="">Select target</option>{(destinationKind === 'sns' ? topics.data?.map((item) => ({value: item.TopicArn, label: item.TopicArn})) : buses.data?.map((item) => ({value: item.Arn, label: item.Name ?? item.Arn})))?.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label> : <label className="dynamic-field dynamic-field--span"><span>CloudWatch dimension</span><input className="input" value={dimensionName} onChange={(event) => setDimensionName(event.target.value)} required/><small>Events publish counts to AWS/SES using this message-tag dimension.</small></label>}<fieldset className="dynamic-field dynamic-field--span"><span>Events</span><div className="button-row">{EVENT_TYPES.map((type) => <label key={type}><input type="checkbox" checked={events.includes(type)} onChange={() => setEvents((current) => current.includes(type) ? current.filter((item) => item !== type) : [...current, type])}/>{type}</label>)}</div></fieldset><button className="button primary" disabled={!destinationName.trim() || events.length === 0 || (destinationKind !== 'cloudwatch' && !destinationTarget) || addDestination.isPending}>Add destination</button></form>{destinations.data?.map((item) => <div className="apigateway-route-item" key={item.EventDestinationName}><div><code>{item.EventDestinationName}</code><small>{item.MatchingEventTypes?.join(', ')} · {item.SnsDestination?.TopicArn ?? item.EventBridgeDestination?.EventBusArn ?? 'CloudWatch'}</small></div><button className="icon-btn danger" onClick={() => deleteSesEventDestination(selectedConfigurationSet, item.EventDestinationName ?? '').then(() => qc.invalidateQueries({queryKey: ['ses-event-destinations', selectedConfigurationSet]}))}><Trash2 size={13}/></button></div>)}</>} {configurationSets.data?.map((item) => <button className="button danger compact" key={`delete-${item.ConfigurationSetName}`} onClick={() => deleteSesConfigurationSet(item.ConfigurationSetName).then(() => qc.invalidateQueries({queryKey: ['ses-configuration-sets']}))}>Delete {item.ConfigurationSetName}</button>)}</section>
|
||||
{error instanceof Error && <p className="error-text compact-text">{error.message}</p>}
|
||||
</div>
|
||||
}
|
||||
@@ -17,6 +17,7 @@ test('receives without deleting and deletes only on explicit action', async () =
|
||||
const user = userEvent.setup(); renderPanel()
|
||||
await user.click(screen.getByRole('button', {name: 'Poll for messages'}))
|
||||
expect(await screen.findByText('hello')).toBeInTheDocument()
|
||||
expect(screen.getByText('hello').closest('pre')).toHaveClass('sqs-message-body')
|
||||
expect(api.deleteSqsMessage).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', {name: 'Delete message message-1'}))
|
||||
await waitFor(() => expect(api.deleteSqsMessage).toHaveBeenCalledWith('aws', 'orders', 'receipt-1'))
|
||||
@@ -31,3 +32,13 @@ test('requires purge confirmation and labels DLQ controls experimental', async (
|
||||
await user.click(screen.getByRole('button', {name: 'Confirm purge'}))
|
||||
await waitFor(() => expect(api.purgeSqsQueue).toHaveBeenCalledWith('aws', 'orders'))
|
||||
})
|
||||
|
||||
test('renders SNS envelopes and their JSON messages separately', async () => {
|
||||
api.receiveSqsMessages.mockResolvedValue([{messageId: 'message-1', receiptHandle: 'receipt-1', body: JSON.stringify({Type: 'Notification', TopicArn: 'arn:aws:sns:us-east-1:000000000000:events', Timestamp: '2026-10-05T14:29:34Z', MessageId: 'sns-message', Message: JSON.stringify({eventType: 'Bounce', bounce: {bounceSubType: 'General'}})}), attributes: {}, messageAttributes: {}}])
|
||||
const user = userEvent.setup(); renderPanel()
|
||||
await user.click(screen.getByRole('button', {name: 'Poll for messages'}))
|
||||
expect(await screen.findByText('SNS notification envelope')).toBeInTheDocument()
|
||||
expect(screen.getByText('SNS message payload')).toBeInTheDocument()
|
||||
expect(screen.getByText(/TopicArn/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/bounceSubType/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -41,13 +41,49 @@ export function SqsQueuePanel({resource, runtimeReachable}: {resource?: CloudRes
|
||||
</form>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Receive</p><h3>Received messages</h3></div><button className="button" type="button" disabled={!runtimeReachable || receive.isPending} onClick={() => receive.mutate()}>{receive.isPending ? 'Polling' : 'Poll for messages'}</button></div>
|
||||
{messages.length === 0 && <p className="muted compact-text">Poll to receive up to 10 messages. Leaving this panel or waiting for the visibility timeout does not delete them.</p>}
|
||||
{messages.map((message) => <article className="apigateway-route-item" key={message.receiptHandle}><div className="apigateway-route-select"><code>{message.messageId}</code><pre>{message.body}</pre><small>Receive count: {message.attributes.ApproximateReceiveCount ?? '-'} · Visibility timeout: {String(resource.metadata.visibilityTimeout ?? 30)}s</small><details><summary>Message details</summary><pre>{JSON.stringify({attributes: message.attributes, messageAttributes: message.messageAttributes, receiptHandle: message.receiptHandle}, null, 2)}</pre></details></div><button className="icon-btn danger" type="button" aria-label={`Delete message ${message.messageId}`} disabled={remove.isPending} onClick={() => remove.mutate(message)}><Trash2 size={14}/></button></article>)}
|
||||
{messages.map((message) => <article className="apigateway-route-item sqs-received-message" key={message.receiptHandle}><div className="apigateway-route-select"><code>{message.messageId}</code><SqsMessageBody body={message.body}/><small>Receive count: {message.attributes.ApproximateReceiveCount ?? '-'} · Visibility timeout: {String(resource.metadata.visibilityTimeout ?? 30)}s</small><details><summary>Message details</summary><pre className="sqs-message-body">{JSON.stringify({attributes: message.attributes, messageAttributes: message.messageAttributes, receiptHandle: message.receiptHandle}, null, 2)}</pre></details></div><button className="icon-btn danger" type="button" aria-label={`Delete message ${message.messageId}`} disabled={remove.isPending} onClick={() => remove.mutate(message)}><Trash2 size={14}/></button></article>)}
|
||||
<section className="apigateway-route-explorer"><p className="eyebrow">Queue management</p>{!purgeConfirm ? <button className="button danger" type="button" onClick={() => setPurgeConfirm(true)}>Purge queue</button> : <div className="apigateway-route-confirm"><span>Purge approximately {String(resource.metadata.approximateMessages ?? 0)} visible messages?</span><button className="button danger compact" type="button" disabled={purge.isPending} onClick={() => purge.mutate()}>Confirm purge</button><button className="button compact" type="button" onClick={() => setPurgeConfirm(false)}>Cancel</button></div>}</section>
|
||||
<section className="apigateway-route-explorer"><p className="eyebrow">Experimental: dead-letter queues</p><p className="muted compact-text">DLQ redrive threshold behavior is not fully AWS-compatible in this runtime. Use only for local experiments.</p><div className="dynamic-form"><label className="dynamic-field"><span>Target DLQ</span><select className="input" value={dlqArn} onChange={(event) => setDlqArn(event.target.value)}><option value="">Select a matching queue</option>{queues.map((queue) => <option key={queue.id} value={String(queue.metadata.arn)}>{queue.name}</option>)}</select></label><label className="dynamic-field"><span>Max receive count</span><input className="input" type="number" min="1" max="1000" value={maxReceiveCount} onChange={(event) => setMaxReceiveCount(event.target.value)}/></label><button className="button" type="button" disabled={!dlqArn || configureDlq.isPending} onClick={() => configureDlq.mutate()}>Save experimental DLQ</button></div><div className="dynamic-form"><label className="dynamic-field"><span>Move destination</span><select className="input" value={moveDestination} onChange={(event) => setMoveDestination(event.target.value)}><option value="">Original source queue</option>{queues.map((queue) => <option key={queue.id} value={String(queue.metadata.arn)}>{queue.name}</option>)}</select></label><button className="button" type="button" disabled={move.isPending} onClick={() => move.mutate()}>Move DLQ messages</button>{move.data && <code>{move.data.taskHandle}</code>}</div></section>
|
||||
{error instanceof Error && <p className="error-text compact-text">{error.message}</p>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function SqsMessageBody({body}: {body: string}) {
|
||||
const notification = snsNotification(body)
|
||||
if (!notification) return <pre className="sqs-message-body">{body}</pre>
|
||||
return <div className="sqs-sns-notification"><p className="metric-label">SNS notification envelope</p><JsonCode value={notification.envelope}/><p className="metric-label">SNS message payload</p>{notification.payload === null ? <pre className="sqs-message-body">{notification.message}</pre> : <JsonCode value={notification.payload}/>}</div>
|
||||
}
|
||||
|
||||
function snsNotification(body: string): {envelope: Record<string, unknown>; message: string; payload: unknown | null} | null {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as Record<string, unknown>
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || parsed.Type !== 'Notification' || typeof parsed.Message !== 'string') return null
|
||||
const {Message, ...envelope} = parsed
|
||||
try {
|
||||
return {envelope, message: Message, payload: JSON.parse(Message)}
|
||||
} catch {
|
||||
return {envelope, message: Message, payload: null}
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function JsonCode({value}: {value: unknown}) {
|
||||
const text = JSON.stringify(value, null, 2)
|
||||
const tokens: Array<{value: string; kind?: string}> = []
|
||||
const expression = /("(?:\\.|[^"\\])*")(?=\s*:)|(\btrue\b|\bfalse\b|\bnull\b)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|("(?:\\.|[^"\\])*")/g
|
||||
let cursor = 0
|
||||
for (const match of text.matchAll(expression)) {
|
||||
const index = match.index ?? 0
|
||||
if (index > cursor) tokens.push({value: text.slice(cursor, index)})
|
||||
tokens.push({value: match[0], kind: match[1] ? 'key' : match[2] ? 'literal' : match[3] ? 'number' : 'string'})
|
||||
cursor = index + match[0].length
|
||||
}
|
||||
if (cursor < text.length) tokens.push({value: text.slice(cursor)})
|
||||
return <pre className="sqs-message-body sqs-json-code">{tokens.map((token, index) => <span className={token.kind ? `json-${token.kind}` : undefined} key={index}>{token.value}</span>)}</pre>
|
||||
}
|
||||
|
||||
function parseAttributes(value: string): Record<string, SqsMessageAttribute> | undefined {
|
||||
if (!value.trim()) return undefined
|
||||
const parsed = JSON.parse(value) as Record<string, SqsMessageAttribute>
|
||||
|
||||
@@ -50,6 +50,9 @@ import snsIcon from '@/assets/aws-services/sns.svg'
|
||||
import sqsIcon from '@/assets/aws-services/sqs.svg'
|
||||
import stepFunctionsIcon from '@/assets/aws-services/step-functions.svg'
|
||||
import vpcIcon from '@/assets/aws-services/vpc.svg'
|
||||
import kinesisIcon from '../../../../aws-icons-svg/Analytics/Kinesis-Data-Streams.svg'
|
||||
import mskIcon from '../../../../aws-icons-svg/Analytics/Managed-Streaming-for-Apache-Kafka.svg'
|
||||
import mskConnectIcon from '../../../../aws-icons-svg/Analytics/MSK Connect Network Logo.svg'
|
||||
|
||||
/**
|
||||
* Maps the server's `iconKey` hint to a component.
|
||||
@@ -87,6 +90,9 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
||||
codebuild: Hammer,
|
||||
codepipeline: Workflow,
|
||||
codedeploy: Workflow,
|
||||
kinesis: Radio,
|
||||
msk: Radio,
|
||||
'msk-connect': Radio,
|
||||
}
|
||||
|
||||
const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
||||
@@ -108,6 +114,9 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
||||
loadbalancing: elasticLoadBalancingIcon,
|
||||
logs: cloudWatchLogsIcon,
|
||||
messaging: sqsIcon,
|
||||
kinesis: kinesisIcon,
|
||||
msk: mskIcon,
|
||||
'msk-connect': mskConnectIcon,
|
||||
nosql: dynamoDbIcon,
|
||||
notifications: snsIcon,
|
||||
networking: vpcIcon,
|
||||
|
||||
@@ -438,6 +438,8 @@ pre,
|
||||
gap: 4px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
@@ -479,6 +481,44 @@ pre,
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.kinesis-records-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.kinesis-record-list {
|
||||
max-height: 340px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.apigateway-workspace {
|
||||
align-items: start;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.apigateway-test-panel {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.kinesis-records-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.apigateway-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.apigateway-test-panel {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -3414,9 +3454,9 @@ pre,
|
||||
}
|
||||
|
||||
.apigateway-test-panel {
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 0;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.apigateway-route-explorer {
|
||||
@@ -3508,6 +3548,44 @@ pre,
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sqs-received-message {
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sqs-received-message .apigateway-route-select {
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sqs-message-body {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
margin: 4px 0;
|
||||
max-height: 320px;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.sqs-sns-notification {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sqs-sns-notification .metric-label {
|
||||
margin: 8px 0 3px;
|
||||
}
|
||||
|
||||
.sqs-json-code .json-key { color: var(--accent); }
|
||||
.sqs-json-code .json-string { color: var(--status-success); }
|
||||
.sqs-json-code .json-number { color: #d99b45; }
|
||||
.sqs-json-code .json-literal { color: #c586c0; }
|
||||
|
||||
.apigateway-route-method {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
@@ -3679,7 +3757,19 @@ pre,
|
||||
}
|
||||
|
||||
.apigateway-request-flow {
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.apigateway-flow-step {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.apigateway-flow-arrow {
|
||||
align-self: center;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
export function decodeRawMime(rawData: string): string {
|
||||
try {
|
||||
return atob(rawData)
|
||||
} catch {
|
||||
return rawData
|
||||
}
|
||||
}
|
||||
|
||||
export function parseMimeAttachments(rawMime: string): Array<{filename: string | null; contentType: string; disposition: string; contentId: string | null}> {
|
||||
const boundary = /^Content-Type:\s*multipart\/[^;]+;\s*boundary="?([^";\r\n]+)"?/im.exec(rawMime)?.[1]
|
||||
if (!boundary) return []
|
||||
return rawMime.split(`--${boundary}`).slice(1, -1).flatMap((part) => {
|
||||
const headerBlock = part.replace(/^\r?\n/, '').split(/\r?\n\r?\n/, 1)[0]
|
||||
const headers = Object.fromEntries(headerBlock.replace(/\r?\n[ \t]+/g, ' ').split(/\r?\n/).flatMap((line) => {
|
||||
const separator = line.indexOf(':')
|
||||
return separator === -1 ? [] : [[line.slice(0, separator).toLowerCase(), line.slice(separator + 1).trim()]]
|
||||
})) as Record<string, string>
|
||||
const contentType = headers['content-type'] ?? 'application/octet-stream'
|
||||
const dispositionHeader = headers['content-disposition'] ?? ''
|
||||
const filename = parameter(dispositionHeader, 'filename') ?? parameter(contentType, 'name')
|
||||
if (!filename && !/attachment|inline/i.test(dispositionHeader)) return []
|
||||
return [{filename, contentType: contentType.split(';', 1)[0], disposition: /^inline/i.test(dispositionHeader) ? 'inline' : 'attachment', contentId: headers['content-id']?.replace(/^<|>$/g, '') ?? null}]
|
||||
})
|
||||
}
|
||||
|
||||
export function inlineMimeImages(rawMime: string): Record<string, string> {
|
||||
const boundary = /^Content-Type:\s*multipart\/[^;]+;\s*boundary="?([^";\r\n]+)"?/im.exec(rawMime)?.[1]
|
||||
if (!boundary) return {}
|
||||
return Object.fromEntries(rawMime.split(`--${boundary}`).slice(1, -1).flatMap((part) => {
|
||||
const [headerBlock, body] = part.replace(/^\r?\n/, '').split(/\r?\n\r?\n/, 2)
|
||||
const contentType = /^Content-Type:\s*([^;\r\n]+)/im.exec(headerBlock)?.[1]?.toLowerCase()
|
||||
const contentId = /^Content-ID:\s*<([^>]+)>/im.exec(headerBlock)?.[1]
|
||||
const encoding = /^Content-Transfer-Encoding:\s*base64/im.test(headerBlock)
|
||||
if (!contentType?.startsWith('image/') || !contentId || !encoding || !body) return []
|
||||
return [[contentId, `data:${contentType};base64,${body.replace(/\s/g, '')}`]]
|
||||
}))
|
||||
}
|
||||
|
||||
export function htmlMimePart(rawMime: string): string | null {
|
||||
const boundary = /^Content-Type:\s*multipart\/[^;]+;\s*boundary="?([^";\r\n]+)"?/im.exec(rawMime)?.[1]
|
||||
if (!boundary) return null
|
||||
for (const part of rawMime.split(`--${boundary}`).slice(1, -1)) {
|
||||
const [headerBlock, body] = part.replace(/^\r?\n/, '').split(/\r?\n\r?\n/, 2)
|
||||
if (!/^Content-Type:\s*text\/html(?:;|\s|$)/im.test(headerBlock) || !body) continue
|
||||
if (/^Content-Transfer-Encoding:\s*base64/im.test(headerBlock)) {
|
||||
try {
|
||||
return atob(body.replace(/\s/g, ''))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parameter(value: string, name: string): string | null {
|
||||
const match = new RegExp(`${name}\\*?=(?:UTF-8''|")?([^;"]+)`, 'i').exec(value)
|
||||
return match?.[1]?.trim() ?? null
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export function CloudExplorerPage() {
|
||||
const routeCloud = normalizeCloud(params.cloud)
|
||||
const cloud = routeCloud ?? 'aws'
|
||||
const service = params.service ?? ''
|
||||
const isAwsMsk = cloud === 'aws' && (service === 'msk' || service === 'msk-connect')
|
||||
const [infoOpen, setInfoOpen] = useState(false)
|
||||
|
||||
const cloudsQuery = useCloudsQuery()
|
||||
@@ -27,7 +28,7 @@ export function CloudExplorerPage() {
|
||||
const schemaQuery = useQuery({
|
||||
queryKey: ['cloud-schema', cloud, service],
|
||||
queryFn: ({signal}) => getServiceSchema(cloud, service, signal),
|
||||
enabled: Boolean(service),
|
||||
enabled: Boolean(service) && !isAwsMsk,
|
||||
})
|
||||
|
||||
const selectedService = useMemo(
|
||||
@@ -46,7 +47,7 @@ export function CloudExplorerPage() {
|
||||
|
||||
// Never redirect while the catalog is still loading: doing so silently sent
|
||||
// any unknown or slow-loading service to storage.
|
||||
if (servicesQuery.isSuccess && !selectedService) {
|
||||
if (servicesQuery.isSuccess && !selectedService && !isAwsMsk) {
|
||||
return <UnknownServiceNotice cloud={cloud} service={service} services={servicesQuery.data}/>
|
||||
}
|
||||
|
||||
@@ -59,7 +60,7 @@ export function CloudExplorerPage() {
|
||||
: <Cloud size={20}/>}
|
||||
<div>
|
||||
<h1>Cloud Explorer</h1>
|
||||
<p className="muted">Unified local runtime console</p>
|
||||
<p className="muted">{isAwsMsk ? (service === 'msk-connect' ? 'Amazon Managed Streaming for Apache Kafka Connect' : 'Amazon Managed Streaming for Apache Kafka') : 'Unified local runtime console'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cloud-header-selectors">
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface FieldSchema {
|
||||
type: FieldType
|
||||
required: boolean
|
||||
requiredWhen?: {field: string; equals: string}
|
||||
visibleWhen?: {field: string; values: string[]}
|
||||
description?: string
|
||||
group?: string
|
||||
span?: boolean
|
||||
|
||||
Generated
+122
-27
@@ -19,6 +19,9 @@ importers:
|
||||
'@aws-sdk/client-cloudformation':
|
||||
specifier: ^3.1111.0
|
||||
version: 3.1111.0
|
||||
'@aws-sdk/client-cloudwatch':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1146.0
|
||||
'@aws-sdk/client-cloudwatch-logs':
|
||||
specifier: ^3.1096.0
|
||||
version: 3.1131.0
|
||||
@@ -55,6 +58,15 @@ importers:
|
||||
'@aws-sdk/client-iam':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1120.0
|
||||
'@aws-sdk/client-kafka':
|
||||
specifier: ^3.1146.0
|
||||
version: 3.1146.0
|
||||
'@aws-sdk/client-kafkaconnect':
|
||||
specifier: ^3.1146.0
|
||||
version: 3.1146.0
|
||||
'@aws-sdk/client-kinesis':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1146.0
|
||||
'@aws-sdk/client-kms':
|
||||
specifier: ^3.1096.0
|
||||
version: 3.1131.0
|
||||
@@ -73,6 +85,9 @@ importers:
|
||||
'@aws-sdk/client-secrets-manager':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1076.0
|
||||
'@aws-sdk/client-sesv2':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1146.0
|
||||
'@aws-sdk/client-sfn':
|
||||
specifier: ^3.1096.0
|
||||
version: 3.1123.0
|
||||
@@ -94,6 +109,9 @@ importers:
|
||||
hono:
|
||||
specifier: ^4.12.27
|
||||
version: 4.12.27
|
||||
kafkajs:
|
||||
specifier: ^2.2.4
|
||||
version: 2.2.4
|
||||
mssql:
|
||||
specifier: ^12.7.0
|
||||
version: 12.7.0
|
||||
@@ -272,6 +290,10 @@ packages:
|
||||
resolution: {integrity: sha512-FmV57mQjXtze2y/TCepl0E+4vvHqXCkiAMn24E5ogYC/xEGAYDPupTkzCQdSUmbyW6/tmPtmcOD+6LbUP1AKog==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-cloudwatch@3.1146.0':
|
||||
resolution: {integrity: sha512-K84tWT/Xe21eSZK7NzkqNT15jctmTg5QjAylDOI2PLcq6yV14ydhXmc/JFptv8kimDC3IKgN3+UoX/NSuXC1jQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-codebuild@3.1144.0':
|
||||
resolution: {integrity: sha512-vLy5q+QNeP1mH6jfIATnVCJb+hTwN8RrZR9syYhyDvH6nu+PiGCSfYpf9z7lqHFiGBGsZbP0DJYfYXVRMrq8ag==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -316,6 +338,18 @@ packages:
|
||||
resolution: {integrity: sha512-w1UNruAHPJ2l6+fNsW3wBYa725ALR1FRQe+rLheeziNK3EmGK6wxTm39dgKyehPmeSXxEQYCg5gcrYq3Ew3CzQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-kafka@3.1146.0':
|
||||
resolution: {integrity: sha512-oN2jvI6i7FHMAKS7iaP+s8DOo3ubCvdM5szrWiq/hqPeorMXqxiq8PE75uHceEwbAjqA8lOM3EQXm3eLnrkEHA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-kafkaconnect@3.1146.0':
|
||||
resolution: {integrity: sha512-KxWNxo0zsQbLrQEozX9ZFazFPxabls2508qsEDOUQVkQOiW6QfiM53F4ks0SKwz2gxn/g2THNqbuaYRS+NZ3yw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-kinesis@3.1146.0':
|
||||
resolution: {integrity: sha512-/WpJG6dIJHKXxqoCNA79SFcbxZCeQ5AVOkVkdzwV1LJDWo/23HFtprUVNldYa50JmqVPkVGO58muAt8jSFN+wA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-kms@3.1131.0':
|
||||
resolution: {integrity: sha512-IQ5pG+aY5bM+1tyduaVa0m2AmPa/G9IZKbtAgtBP0pcM6wRkOlM3QyQEmi2UOoGd6L+ma738yOa6MmH6V3OLxw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -340,6 +374,10 @@ packages:
|
||||
resolution: {integrity: sha512-N6azFky/nhHaBfLxza6fMnLbOCgqO12StpCgMDVzx43W7phexcWT61r2mHWrVK6VA6TU+Bze12ZQUb017+VWpw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-sesv2@3.1146.0':
|
||||
resolution: {integrity: sha512-3sMaymLxzaP1Ldqmu8mU4Uj2qLMDl8hHb3WYiM+H2fFHOnpTGVSToB5s15qCb0sAWqbXmFf+0vBLfwazpB8LzA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-sfn@3.1123.0':
|
||||
resolution: {integrity: sha512-C9O/NfDJCXCyXqMh4qrEQ03K4YLXlJOxvwrpcIB+6GJ9HsxRVjFhS73E1m9uO6d5A6Quk+efTDXfAbKmwVvKlw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -643,14 +681,6 @@ packages:
|
||||
resolution: {integrity: sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.42':
|
||||
resolution: {integrity: sha512-DBV4naZP6HYBlAvPpoQzOP12Wvfou/5rN8yJPXjBTBylU5qwCbh/tXr2MddHoIjgoRkEl/eS+IljiUqvmwey1Q==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.45':
|
||||
resolution: {integrity: sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.46':
|
||||
resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -1174,6 +1204,10 @@ packages:
|
||||
resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/middleware-compression@4.6.2':
|
||||
resolution: {integrity: sha512-Q9d+luiRjyHT6kCL/9NyGpdZJgodh4vtvfHC6H8SqoVKrV2k9RoyI9/IloVfdCYks3/2DIi7BsYYLcda5KZS0A==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/node-http-handler@4.11.0':
|
||||
resolution: {integrity: sha512-ssHIZsadPUA3lGdnoByxfnjtb9xPYQLvdfJRLKIwxOoa6tO1suG4sLFSsgd7D/CsvYd8QbBIuKTImuJha5l6aQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -1804,6 +1838,9 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fflate@0.8.3:
|
||||
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@@ -1963,6 +2000,10 @@ packages:
|
||||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
kafkajs@2.2.4:
|
||||
resolution: {integrity: sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -2745,6 +2786,18 @@ snapshots:
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-cloudwatch@3.1146.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/credential-provider-node': 3.972.84
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/middleware-compression': 4.6.2
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-codebuild@3.1144.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
@@ -2876,6 +2929,39 @@ snapshots:
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-kafka@3.1146.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/credential-provider-node': 3.972.84
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-kafkaconnect@3.1146.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/credential-provider-node': 3.972.84
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-kinesis@3.1146.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/credential-provider-node': 3.972.84
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-kms@3.1131.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
@@ -2955,6 +3041,18 @@ snapshots:
|
||||
'@smithy/types': 4.15.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-sesv2@3.1146.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/credential-provider-node': 3.972.84
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-sfn@3.1123.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
@@ -3653,7 +3751,7 @@ snapshots:
|
||||
'@aws-sdk/nested-clients@3.997.28':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.38
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
@@ -3664,7 +3762,7 @@ snapshots:
|
||||
'@aws-sdk/nested-clients@3.997.36':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.42
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
@@ -3675,7 +3773,7 @@ snapshots:
|
||||
'@aws-sdk/nested-clients@3.997.43':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.45
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
@@ -3686,7 +3784,7 @@ snapshots:
|
||||
'@aws-sdk/nested-clients@3.997.44':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.46
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
@@ -3697,7 +3795,7 @@ snapshots:
|
||||
'@aws-sdk/nested-clients@3.997.45':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.46
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
@@ -3723,20 +3821,6 @@ snapshots:
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.42':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/signature-v4': 5.7.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.45':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/signature-v4': 5.7.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.46':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.974.5
|
||||
@@ -4367,6 +4451,13 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/middleware-compression@4.6.2':
|
||||
dependencies:
|
||||
'@smithy/core': 3.35.0
|
||||
'@smithy/types': 4.19.0
|
||||
fflate: 0.8.3
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/node-http-handler@4.11.0':
|
||||
dependencies:
|
||||
'@smithy/core': 3.33.3
|
||||
@@ -5056,6 +5147,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.5
|
||||
|
||||
fflate@0.8.3: {}
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
dependencies:
|
||||
flat-cache: 4.0.1
|
||||
@@ -5216,6 +5309,8 @@ snapshots:
|
||||
jwa: 2.0.1
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
kafkajs@2.2.4: {}
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
|
||||
Reference in New Issue
Block a user