feat: MSK, MSK CONNECT and SES

This commit is contained in:
Zakaria
2026-10-05 11:06:57 -04:00
parent 2d8ff50a99
commit c0d3e5f402
84 changed files with 3513 additions and 195 deletions
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

+6
View File
@@ -13,6 +13,7 @@
"@aws-sdk/client-api-gateway": "^3.1076.0", "@aws-sdk/client-api-gateway": "^3.1076.0",
"@aws-sdk/client-appconfig": "^3.1076.0", "@aws-sdk/client-appconfig": "^3.1076.0",
"@aws-sdk/client-cloudformation": "^3.1111.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-cloudwatch-logs": "^3.1096.0",
"@aws-sdk/client-codebuild": "^3.1076.0", "@aws-sdk/client-codebuild": "^3.1076.0",
"@aws-sdk/client-codedeploy": "^3.1144.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-elastic-load-balancing-v2": "^3.1096.0",
"@aws-sdk/client-eventbridge": "^3.1076.0", "@aws-sdk/client-eventbridge": "^3.1076.0",
"@aws-sdk/client-iam": "^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-kms": "^3.1096.0",
"@aws-sdk/client-lambda": "^3.1076.0", "@aws-sdk/client-lambda": "^3.1076.0",
"@aws-sdk/client-rds": "^3.1076.0", "@aws-sdk/client-rds": "^3.1076.0",
"@aws-sdk/client-rds-data": "^3.1076.0", "@aws-sdk/client-rds-data": "^3.1076.0",
"@aws-sdk/client-s3": "^3.1076.0", "@aws-sdk/client-s3": "^3.1076.0",
"@aws-sdk/client-secrets-manager": "^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-sfn": "^3.1096.0",
"@aws-sdk/client-sns": "^3.1140.0", "@aws-sdk/client-sns": "^3.1140.0",
"@aws-sdk/client-sqs": "^3.1076.0", "@aws-sdk/client-sqs": "^3.1076.0",
@@ -38,6 +43,7 @@
"@aws-sdk/util-dynamodb": "^3.996.7", "@aws-sdk/util-dynamodb": "^3.996.7",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"hono": "^4.12.27", "hono": "^4.12.27",
"kafkajs": "^2.2.4",
"mssql": "^12.7.0", "mssql": "^12.7.0",
"pg": "^8.22.0" "pg": "^8.22.0"
}, },
@@ -24,7 +24,7 @@ describe('AwsCodeBuildAdapter', () => {
test('creates a privileged Docker-to-ECR project with no artifacts', async () => { test('creates a privileged Docker-to-ECR project with no artifacts', async () => {
const {client, sent} = stub((command) => command instanceof CreateProjectCommand ? {project} : {}) 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'}}) 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 () => { 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> { async create(input: CreateResourceInput): Promise<CloudResource> {
const name = required(input.values.name, 'name') const name = required(input.values.name, 'name')
const serviceRole = required(input.values.serviceRole, 'serviceRole') 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 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') if (computeType !== ComputeType.BUILD_GENERAL1_SMALL) throw new ValidationError('computeType must be BUILD_GENERAL1_SMALL')
const sourceType = supportedType(input.values.sourceType, 'sourceType', 'NO_SOURCE') const sourceType = supportedType(input.values.sourceType, 'sourceType', 'NO_SOURCE')
@@ -1,9 +1,49 @@
import {describe, expect, test} from 'bun:test' 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 type {LambdaClient} from '@aws-sdk/client-lambda'
import {AwsCodeDeployAdapter} from './AwsCodeDeployAdapter' import {AwsCodeDeployAdapter} from './AwsCodeDeployAdapter'
describe('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 () => { test('creates a native Lambda AppSpec revision with traffic hooks', async () => {
const sent: object[] = [] const sent: object[] = []
const client = {async send(command: object) { const client = {async send(command: object) {
@@ -47,15 +47,30 @@ export class AwsCodeDeployAdapter implements CloudServiceAdapter {
async create(input: CreateResourceInput): Promise<CloudResource> { async create(input: CreateResourceInput): Promise<CloudResource> {
const applicationName = required(input.values.applicationName, 'applicationName') const applicationName = required(input.values.applicationName, 'applicationName')
const deploymentGroupName = required(input.values.deploymentGroupName, 'deploymentGroupName') 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({})) 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({ await this.client.send(new CreateDeploymentGroupCommand({
applicationName, applicationName,
deploymentGroupName, deploymentGroupName,
serviceRoleArn: required(input.values.serviceRoleArn, 'serviceRoleArn'), 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'}, deploymentStyle: {deploymentType: 'BLUE_GREEN', deploymentOption: 'WITH_TRAFFIC_CONTROL'},
autoRollbackConfiguration: {enabled: input.values.rollbackOnFailure !== false, events: ['DEPLOYMENT_FAILURE']}, 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) return this.groupResource(applicationName, deploymentGroupName)
} }
@@ -117,14 +132,15 @@ export class AwsCodeDeployAdapter implements CloudServiceAdapter {
const group = response.deploymentGroupInfo const group = response.deploymentGroupInfo
if (!group?.deploymentGroupName) throw new RuntimeError(`CodeDeploy deployment group ${deploymentGroupName} was not found`) if (!group?.deploymentGroupName) throw new RuntimeError(`CodeDeploy deployment group ${deploymentGroupName} was not found`)
const configuration = group.deploymentConfigName 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 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 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 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 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 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} } } 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 {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 {DescribeLoadBalancersCommand, DescribeTargetGroupsCommand, DescribeTargetHealthCommand, type ElasticLoadBalancingV2Client} from '@aws-sdk/client-elastic-load-balancing-v2'
import {AwsEcsAdapter} from './AwsEcsAdapter' import {AwsEcsAdapter} from './AwsEcsAdapter'
import {AwsFargateAdapter} from './AwsFargateAdapter' import {AwsFargateAdapter} from './AwsFargateAdapter'
@@ -44,13 +44,59 @@ describe('AwsEcsAdapter', () => {
const elb = {async send() { return {} }} as unknown as ElasticLoadBalancingV2Client const elb = {async send() { return {} }} as unknown as ElasticLoadBalancingV2Client
const adapter = new AwsEcsAdapter(ecs, elb) 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`) 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}) 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 () => { test('does not present stale inactive counts as running', async () => {
const ecs = {async send(command: object) { 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}]}]} if (command instanceof DescribeServicesCommand) return {services: [{serviceName: 'orders', status: 'INACTIVE', desiredCount: 2, runningCount: 2, pendingCount: 1, deployments: [{desiredCount: 2, runningCount: 2}]}]}
+208 -31
View File
@@ -1,9 +1,31 @@
import {CreateServiceCommand, DeleteServiceCommand, DescribeServicesCommand, DescribeTasksCommand, ListClustersCommand, ListServicesCommand, ListTasksCommand, type ECSClient, type Service} from '@aws-sdk/client-ecs' import {
CreateClusterCommand,
CreateServiceCommand,
DeleteClusterCommand,
DeleteServiceCommand,
DeregisterTaskDefinitionCommand,
DescribeClustersCommand,
DescribeServicesCommand,
DescribeTaskDefinitionCommand,
DescribeTasksCommand,
ListClustersCommand,
ListServicesCommand,
ListTaskDefinitionsCommand,
ListTasksCommand,
RegisterTaskDefinitionCommand,
type Cluster,
type ECSClient,
type Service,
type TaskDefinition,
} from '@aws-sdk/client-ecs'
import {DescribeLoadBalancersCommand, DescribeTargetGroupsCommand, DescribeTargetHealthCommand, type ElasticLoadBalancingV2Client} from '@aws-sdk/client-elastic-load-balancing-v2' import {DescribeLoadBalancersCommand, DescribeTargetGroupsCommand, DescribeTargetHealthCommand, type ElasticLoadBalancingV2Client} from '@aws-sdk/client-elastic-load-balancing-v2'
import {RuntimeError, ValidationError} from '../cloud-spi/errors' import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import {awsEcsSchema} from '../cloud-spi/ecsSchema' import {awsEcsSchema} from '../cloud-spi/ecsSchema'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types' 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 { export class AwsEcsAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const readonly cloud = 'aws' as const
readonly service = 'ecs' as const readonly service = 'ecs' as const
@@ -13,33 +35,101 @@ export class AwsEcsAdapter implements CloudServiceAdapter {
schema(): ServiceSchema { return awsEcsSchema() } schema(): ServiceSchema { return awsEcsSchema() }
async list(query: ResourceQuery = {}): Promise<CloudResource[]> { async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const resources: CloudResource[] = [] const [clusterArns, taskDefinitions] = await Promise.all([this.clusterArns(), this.taskDefinitions()])
let clusterToken: string | undefined const [clusters, services] = await Promise.all([
do { this.describeClusters(clusterArns),
const clusters = await this.client.send(new ListClustersCommand(clusterToken ? {nextToken: clusterToken} : {})) Promise.all(clusterArns.map(async (cluster) => Promise.all((await this.services(cluster)).map((service) => this.toServiceResource(cluster, service))))),
for (const cluster of clusters.clusterArns ?? []) { ])
const services = await this.services(cluster) const resources = [
resources.push(...await Promise.all(services.map((service) => this.toResource(cluster, service)))) ...clusters.map(toClusterResource),
} ...taskDefinitions.map(toTaskDefinitionResource),
clusterToken = clusters.nextToken ...services.flat(),
} while (clusterToken) ]
const search = query.search?.trim().toLowerCase() 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> { 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 if (!cluster || !serviceName) return null
const response = await this.client.send(new DescribeServicesCommand({cluster, services: [serviceName]})) const response = await this.client.send(new DescribeServicesCommand({cluster, services: [serviceName]}))
const service = response.services?.[0] 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> { 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 cluster = required(input.values.cluster, 'cluster')
const serviceName = required(input.values.serviceName, 'serviceName') const serviceName = required(input.values.serviceName, 'serviceName')
const taskDefinition = required(input.values.taskDefinition, 'taskDefinition') const taskDefinition = required(input.values.taskDefinition, 'taskDefinition')
const launchType = oneOf(input.values.launchType, ['EC2', 'FARGATE'], 'launchType') as 'EC2' | 'FARGATE' | undefined ?? 'FARGATE' 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 desiredCount = count(input.values.desiredCount)
const subnets = ids(input.values.subnets, 'subnets') const subnets = ids(input.values.subnets, 'subnets')
if (launchType === 'FARGATE' && subnets.length === 0) throw new ValidationError('subnets is required for Fargate awsvpc networking') 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, taskDefinition,
desiredCount, desiredCount,
launchType, launchType,
deploymentController: {type: deploymentController},
...(subnets.length ? {networkConfiguration: {awsvpcConfiguration: {subnets, ...(securityGroups.length ? {securityGroups} : {}), assignPublicIp: input.values.assignPublicIp === true || input.values.assignPublicIp === 'ENABLED' ? 'ENABLED' : 'DISABLED'}}} : {}), ...(subnets.length ? {networkConfiguration: {awsvpcConfiguration: {subnets, ...(securityGroups.length ? {securityGroups} : {}), assignPublicIp: input.values.assignPublicIp === true || input.values.assignPublicIp === 'ENABLED' ? 'ENABLED' : 'DISABLED'}}} : {}),
...(targetGroupArn ? {loadBalancers: [{targetGroupArn, containerName, containerPort}]} : {}), ...(targetGroupArn ? {loadBalancers: [{targetGroupArn, containerName, containerPort}]} : {}),
})) }))
if (!response.service) throw new RuntimeError(`ECS did not return the created service ${serviceName}`) 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> { private async clusterArns(): Promise<string[]> {
const [cluster, serviceName] = splitId(id) const result: string[] = []
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[] = []
let nextToken: string | undefined let nextToken: string | undefined
do { do {
const listed = await this.client.send(new ListServicesCommand(nextToken ? {cluster, nextToken} : {cluster})) const response = await this.client.send(new ListClustersCommand(nextToken ? {nextToken} : {}))
if (listed.serviceArns?.length) { result.push(...(response.clusterArns ?? []))
const described = await this.client.send(new DescribeServicesCommand({cluster, services: listed.serviceArns})) nextToken = response.nextToken
result.push(...(described.services ?? []))
}
nextToken = listed.nextToken
} while (nextToken) } while (nextToken)
return result 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 name = service.serviceName ?? service.serviceArn ?? ''
const inactive = service.status === 'INACTIVE' const inactive = service.status === 'INACTIVE'
const [tasks, loadBalancers] = inactive ? [[], []] : await Promise.all([this.tasks(cluster, name), this.loadBalancerHealth(service)]) 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, createdAt: service.createdAt?.toISOString() ?? null, status: service.status ?? null,
metadata: { metadata: {
cluster, serviceArn: service.serviceArn, launchType: service.launchType ?? 'EC2', taskDefinition: service.taskDefinition, 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, desiredCount: inactive ? 0 : service.desiredCount ?? 0, runningCount: inactive ? 0 : service.runningCount ?? 0, pendingCount: inactive ? 0 : service.pendingCount ?? 0,
inactive, inactive,
networkConfiguration: service.networkConfiguration?.awsvpcConfiguration ? {subnets: service.networkConfiguration.awsvpcConfiguration.subnets ?? [], securityGroups: service.networkConfiguration.awsvpcConfiguration.securityGroups ?? [], assignPublicIp: service.networkConfiguration.awsvpcConfiguration.assignPublicIp ?? 'DISABLED'} : null, 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 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 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 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 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 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 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 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 {awsNetworkingSchema} from '../cloud-spi/networkingSchema'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types' 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 = { type Ec2NetworkingServiceShape = {
listVpcs(): Promise<Ec2Vpc[]> 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 { export class AwsNetworkingAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const readonly cloud = 'aws' as const
readonly service = 'networking' 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 { schema(): ServiceSchema {
return awsNetworkingSchema() 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 { function vpcToResource(vpc: Ec2Vpc): CloudResource {
return { return {
id: vpc.vpcId, id: vpc.vpcId,
@@ -5,6 +5,7 @@ import {
CreateEventSourceMappingCommand, CreateEventSourceMappingCommand,
DeleteFunctionCommand, DeleteFunctionCommand,
DeleteEventSourceMappingCommand, DeleteEventSourceMappingCommand,
UpdateEventSourceMappingCommand,
GetFunctionCommand, GetFunctionCommand,
InvokeCommand, InvokeCommand,
type LambdaClient, type LambdaClient,
@@ -450,4 +451,27 @@ describe('AwsServerlessAdapter', () => {
expect((sent[0] as DeleteEventSourceMappingCommand).input).toEqual({UUID: 'mapping-1'}) 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, AddPermissionCommand,
DeleteFunctionCommand, DeleteFunctionCommand,
DeleteEventSourceMappingCommand, DeleteEventSourceMappingCommand,
UpdateEventSourceMappingCommand,
GetFunctionCommand, GetFunctionCommand,
InvokeCommand, InvokeCommand,
type InvokeCommandOutput, type InvokeCommandOutput,
@@ -34,6 +35,8 @@ import type {
EventBridgeLambdaTriggerOverview, EventBridgeLambdaTriggerOverview,
SqsLambdaEventSourceMappingInput, SqsLambdaEventSourceMappingInput,
SqsLambdaEventSourceMappingOverview, SqsLambdaEventSourceMappingOverview,
LambdaEventSourceMappingInput,
LambdaEventSourceMappingOverview,
ServerlessInvokeResult, ServerlessInvokeResult,
ServiceSchema, ServiceSchema,
UpdateResourceInput, UpdateResourceInput,
@@ -385,6 +388,57 @@ export class AwsServerlessAdapter implements CloudServiceAdapter {
await this.lambda.send(new DeleteEventSourceMappingCommand({UUID: uuid})) 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) { private async listEventBridgeTargets(ruleName: string) {
const targets = [] const targets = []
let nextToken: string | undefined let nextToken: string | undefined
@@ -457,6 +511,22 @@ function sqsBatchSize(value: number): number {
return value 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( function sqsEventSourceMappingOverview(
mapping: {UUID?: string; FunctionArn?: string; EventSourceArn?: string; BatchSize?: number; State?: string; StateTransitionReason?: string}, mapping: {UUID?: string; FunctionArn?: string; EventSourceArn?: string; BatchSize?: number; State?: string; StateTransitionReason?: string},
functionName: string, functionName: string,
+16
View File
@@ -23,6 +23,11 @@ import { CodeBuildClient } from "@aws-sdk/client-codebuild";
import { CodePipelineClient } from "@aws-sdk/client-codepipeline"; import { CodePipelineClient } from "@aws-sdk/client-codepipeline";
import { CodeDeployClient } from "@aws-sdk/client-codedeploy"; import { CodeDeployClient } from "@aws-sdk/client-codedeploy";
import { ECSClient } from "@aws-sdk/client-ecs"; 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 endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1"; const region = process.env.AWS_REGION || "us-east-1";
@@ -77,6 +82,11 @@ export type AwsClients = {
codepipeline: CodePipelineClient; codepipeline: CodePipelineClient;
codedeploy: CodeDeployClient; codedeploy: CodeDeployClient;
ecs: ECSClient; ecs: ECSClient;
kafka: KafkaClient;
kafkaConnect: KafkaConnectClient;
kinesis: KinesisClient;
sesv2: SESv2Client;
cloudwatch: CloudWatchClient;
}; };
export type AwsClientName = keyof AwsClients; export type AwsClientName = keyof AwsClients;
@@ -115,6 +125,11 @@ function buildClients(accountId: string): AwsClients {
codepipeline: new CodePipelineClient(base), codepipeline: new CodePipelineClient(base),
codedeploy: new CodeDeployClient(base), codedeploy: new CodeDeployClient(base),
ecs: new ECSClient(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 apiGateway = awsClients.apiGateway;
export const cloudformation = awsClients.cloudformation; export const cloudformation = awsClients.cloudformation;
export const ecr = awsClients.ecr; 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}] 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. */ /** The console exposes only source and artifact modes the local runtime can execute. */
export function awsCodeBuildSchema(): ServiceSchema { export function awsCodeBuildSchema(): ServiceSchema {
return { return {
@@ -33,8 +35,8 @@ export function awsCodeBuildSchema(): ServiceSchema {
}, },
{ {
name: 'buildspec', label: 'Buildspec', type: 'textarea', required: false, group: 'Source', span: true, 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: ''}}, defaultValueWhen: {field: 'sourceType', values: {NO_SOURCE: DEFAULT_DOCKER_TO_ECR_BUILDSPEC, 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.', 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', 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', name: 'environmentImage', label: 'Runtime image', type: 'select', required: true, group: 'Environment',
defaultValue: 'aws/codebuild/standard:7.0', defaultValue: 'node:20-bookworm',
options: [ options: [
{label: 'CodeBuild Standard 7.0', value: 'aws/codebuild/standard:7.0'}, {label: 'CodeBuild Standard 7.0', value: 'aws/codebuild/standard:7.0'},
{label: 'Node.js 20 (Debian)', value: 'node:20-bookworm'}, {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', name: 'dockerPrivilegedMode', label: 'Docker daemon', type: 'select', required: true, group: 'Docker and ECR', defaultValue: 'enabled',
options: [{label: 'Enabled (required for Docker builds)', value: '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'], actions: ['list', 'create', 'delete', 'inspect'],
@@ -3,6 +3,7 @@ import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types'
const columns: TableColumnSchema[] = [ const columns: TableColumnSchema[] = [
{name: 'name', label: 'Deployment group'}, {name: 'name', label: 'Deployment group'},
{name: 'applicationName', label: 'Application', path: 'metadata.applicationName'}, {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: 'deploymentConfigName', label: 'Configuration', path: 'metadata.deploymentConfigName', format: 'badge'},
{name: 'updatedAt', label: 'Updated', path: 'metadata.updatedAt', format: 'datetime'}, {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: '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: '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: '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'], actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {resourceActions: [ capabilities: {resourceActions: [
+29 -27
View File
@@ -1,7 +1,8 @@
import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types' import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types'
const columns: TableColumnSchema[] = [ const columns: TableColumnSchema[] = [
{name: 'name', label: 'Service'}, {name: 'name', label: 'Resource'},
{name: 'type', label: 'Type', format: 'badge'},
{name: 'status', label: 'Status', format: 'badge'}, {name: 'status', label: 'Status', format: 'badge'},
{name: 'launchType', label: 'Launch type', path: 'metadata.launchType', format: 'badge'}, {name: 'launchType', label: 'Launch type', path: 'metadata.launchType', format: 'badge'},
{name: 'cluster', label: 'Cluster', path: 'metadata.cluster', format: 'code'}, {name: 'cluster', label: 'Cluster', path: 'metadata.cluster', format: 'code'},
@@ -16,24 +17,31 @@ export function awsEcsSchema(): ServiceSchema {
return { return {
cloud: 'aws', service: 'ecs', displayName: 'Amazon ECS', cloud: 'aws', service: 'ecs', displayName: 'Amazon ECS',
fields: [ fields: [
{name: 'cluster', label: 'Cluster', type: 'text', required: true, group: 'Required'}, {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: 'serviceName', label: 'Service name', type: 'text', required: true, group: 'Required'}, {name: 'clusterName', label: 'New cluster name', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'cluster'}, visibleWhen: {field: 'resourceKind', values: ['cluster']}, group: 'Cluster'},
{name: 'taskDefinition', label: 'Task definition', type: 'text', required: true, group: 'Required'}, {name: 'family', label: 'Task family', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'taskDefinition'}, visibleWhen: {field: 'resourceKind', values: ['taskDefinition']}, group: 'Fargate task definition'},
{name: 'launchType', label: 'Launch type', type: 'select', required: true, defaultValue: 'FARGATE', group: 'Required', options: [{label: 'Fargate', value: 'FARGATE'}, {label: 'EC2', value: 'EC2'}]}, {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: 'desiredCount', label: 'Desired tasks', type: 'text', required: false, defaultValue: '1', group: 'Required'}, {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: '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: '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: 'securityGroups', label: 'Security group IDs', type: 'text', required: false, group: 'awsvpc networking', span: true, description: 'Optional comma-separated groups for awsvpc tasks.'}, {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: 'assignPublicIp', label: 'Assign public IP', type: 'select', required: false, defaultValue: 'DISABLED', group: 'awsvpc networking', options: [{label: 'Disabled', value: 'DISABLED'}, {label: 'Enabled', value: 'ENABLED'}]}, {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: '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: 'cluster', label: 'Cluster', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'service'}, visibleWhen: {field: 'resourceKind', values: ['service']}, group: 'Fargate service'},
{name: 'containerName', label: 'Container name', type: 'text', required: false, group: 'Load balancing'}, {name: 'serviceName', label: 'Service name', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'service'}, visibleWhen: {field: 'resourceKind', values: ['service']}, group: 'Fargate service'},
{name: 'containerPort', label: 'Container port', type: 'text', required: false, group: 'Load balancing'}, {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'], actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {resourceActions: [ capabilities: {resourceActions: [
{name: 'list', label: 'List ECS services', enabled: true, status: 'available', runtimeRequired: true}, {name: 'list', label: 'List ECS resources', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect service health', enabled: true, status: 'available', runtimeRequired: true}, {name: 'inspect', label: 'Inspect ECS resource', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create EC2 or Fargate service', enabled: true, status: 'available', runtimeRequired: true}, {name: 'create', label: 'Create ECS resource', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Force delete service', enabled: true, status: 'available', runtimeRequired: true}, {name: 'delete', label: 'Delete ECS resource', enabled: true, status: 'available', runtimeRequired: true},
]}, ]},
filters, columns, filters, columns,
} }
@@ -46,18 +54,12 @@ export function awsFargateSchema(): ServiceSchema {
service: 'fargate', service: 'fargate',
displayName: 'AWS Fargate', displayName: 'AWS Fargate',
fields: ecs.fields fields: ecs.fields
.filter((field) => field.name !== 'launchType') .filter((field) => !['resourceKind', 'clusterName', 'family', 'image', 'cpu', 'memory', 'launchType'].includes(field.name))
.map((field) => field.name === 'subnets' ? { .map((field) => ['cluster', 'serviceName', 'taskDefinition', 'subnets'].includes(field.name)
...field, ? {...field, required: true, requiredWhen: undefined}
required: true, : field),
requiredWhen: undefined,
description: 'Required for Fargate awsvpc task ENIs. Comma-separated subnets are used for awsvpc networking.',
} : field),
capabilities: { capabilities: {
resourceActions: ecs.capabilities?.resourceActions?.map((action) => action.name === 'create' ? { resourceActions: ecs.capabilities?.resourceActions?.map((action) => action.name === 'create' ? {...action, label: 'Create Fargate service'} : action),
...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, 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' | 'Databases'
| 'Networking' | 'Networking'
| 'Integration' | 'Integration'
| 'Analytics'
| 'Provisioning' | 'Provisioning'
| 'Security' | 'Security'
| 'Observability' | 'Observability'
@@ -32,6 +33,7 @@ export const SERVICE_GROUP_ORDER: ServiceGroup[] = [
'Databases', 'Databases',
'Networking', 'Networking',
'Integration', 'Integration',
'Analytics',
'Provisioning', 'Provisioning',
'Security', 'Security',
'Observability', 'Observability',
@@ -171,6 +173,29 @@ export const SERVICE_CATALOG = {
group: 'Integration', group: 'Integration',
order: 15, 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}, identity: {displayName: 'Identity', iconKey: 'iam', group: 'Security', order: 5},
apigateway: {displayName: 'API Gateway', iconKey: 'apigateway', group: 'Integration', order: 10}, apigateway: {displayName: 'API Gateway', iconKey: 'apigateway', group: 'Integration', order: 10},
email: { email: {
+38
View File
@@ -85,6 +85,7 @@ export interface FieldSchema {
type: FieldType type: FieldType
required: boolean required: boolean
requiredWhen?: {field: string; equals: string} requiredWhen?: {field: string; equals: string}
visibleWhen?: {field: string; values: string[]}
description?: string description?: string
group?: string group?: string
span?: boolean span?: boolean
@@ -697,6 +698,37 @@ export interface SqsLambdaEventSourceMappingOverview {
stateTransitionReason: string | null 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 = export type KmsEncryptionAlgorithm =
| 'SYMMETRIC_DEFAULT' | 'SYMMETRIC_DEFAULT'
| 'RSAES_OAEP_SHA_1' | 'RSAES_OAEP_SHA_1'
@@ -791,6 +823,12 @@ export interface CloudServiceAdapter {
createSqsLambdaEventSourceMapping?(input: SqsLambdaEventSourceMappingInput): Promise<SqsLambdaEventSourceMappingOverview> createSqsLambdaEventSourceMapping?(input: SqsLambdaEventSourceMappingInput): Promise<SqsLambdaEventSourceMappingOverview>
listSqsLambdaEventSourceMappings?(functionName: string): Promise<SqsLambdaEventSourceMappingOverview[]> listSqsLambdaEventSourceMappings?(functionName: string): Promise<SqsLambdaEventSourceMappingOverview[]>
deleteSqsLambdaEventSourceMapping?(functionName: string, mappingId: string): Promise<void> 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> encrypt?(id: string, input: KmsEncryptInput): Promise<KmsEncryptResult>
decrypt?(id: string, input: KmsDecryptInput): Promise<KmsDecryptResult> decrypt?(id: string, input: KmsDecryptInput): Promise<KmsDecryptResult>
// Lifecycle verbs. Optional because most categories have no notion of them; // Lifecycle verbs. Optional because most categories have no notion of them;
+2
View File
@@ -44,6 +44,7 @@ import {AwsCodePipelineAdapter} from './adapter-aws/AwsCodePipelineAdapter'
import {AwsCodeDeployAdapter} from './adapter-aws/AwsCodeDeployAdapter' import {AwsCodeDeployAdapter} from './adapter-aws/AwsCodeDeployAdapter'
import {AwsEcsAdapter} from './adapter-aws/AwsEcsAdapter' import {AwsEcsAdapter} from './adapter-aws/AwsEcsAdapter'
import {AwsFargateAdapter} from './adapter-aws/AwsFargateAdapter' import {AwsFargateAdapter} from './adapter-aws/AwsFargateAdapter'
import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws' import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2' import {createEc2Service} from './services/ec2'
import {createEksService} from './services/eks' import {createEksService} from './services/eks'
@@ -90,6 +91,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsCodeDeployAdapter(clients.codedeploy, clients.lambda), new AwsCodeDeployAdapter(clients.codedeploy, clients.lambda),
new AwsEcsAdapter(clients.ecs, clients.elbv2), new AwsEcsAdapter(clients.ecs, clients.elbv2),
new AwsFargateAdapter(new AwsEcsAdapter(clients.ecs, clients.elbv2)), new AwsFargateAdapter(new AwsEcsAdapter(clients.ecs, clients.elbv2)),
new AwsKinesisAdapter(clients.kinesis),
new AzureStorageAdapter(), new AzureStorageAdapter(),
new AzureServiceBusAdapter(), new AzureServiceBusAdapter(),
new AzureDatabaseAdapter(), new AzureDatabaseAdapter(),
+6
View File
@@ -7,6 +7,9 @@ import eks from "./routes/eks";
import ec2 from "./routes/ec2"; import ec2 from "./routes/ec2";
import secretsmanager from "./routes/secretsmanager"; import secretsmanager from "./routes/secretsmanager";
import clouds from "./routes/clouds"; import clouds from "./routes/clouds";
import msk from "./routes/msk";
import mskConnect from "./routes/mskConnect";
import ses from "./routes/ses";
const app = new Hono(); const app = new Hono();
// Secrets Manager and KMS crypto routes handle sensitive values with server-side // 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/ec2", ec2);
app.route("/api/secretsmanager", secretsmanager); app.route("/api/secretsmanager", secretsmanager);
app.route("/api/clouds", clouds); 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) // Serve static frontend files when public/ directory is present (production)
app.use("*", serveStatic({ root: "./public" })); app.use("*", serveStatic({ root: "./public" }));
+58
View File
@@ -963,6 +963,64 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
}) })
}) })
app.post('/:cloud/services/kinesis/resources/:id/records', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const input = await jsonBody<{partitionKey?: unknown; data?: unknown}>(c)
return c.json(await svc(c).putKinesisRecord(cloud, c.req.param('id'), {
partitionKey: typeof input.partitionKey === 'string' ? input.partitionKey : '',
data: typeof input.data === 'string' ? input.data : '',
}), 201)
})
})
app.get('/:cloud/services/kinesis/resources/:id/records', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).listKinesisRecords(cloud, c.req.param('id'))))
})
app.get('/:cloud/services/serverless/event-source-mappings', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).listLambdaEventSourceMappings(cloud, {
functionName: c.req.query('functionName'), eventSourceArn: c.req.query('eventSourceArn'),
})))
})
app.post('/:cloud/services/serverless/event-source-mappings', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const input = await jsonBody<{functionName?: unknown; eventSourceArn?: unknown; batchSize?: unknown; startingPosition?: unknown}>(c)
return c.json(await svc(c).createLambdaEventSourceMapping(cloud, {
functionName: typeof input.functionName === 'string' ? input.functionName : '',
eventSourceArn: typeof input.eventSourceArn === 'string' ? input.eventSourceArn : '',
batchSize: typeof input.batchSize === 'number' ? input.batchSize : undefined,
startingPosition: input.startingPosition === 'TRIM_HORIZON' || input.startingPosition === 'LATEST' ? input.startingPosition : undefined,
}), 201)
})
})
app.patch('/:cloud/services/serverless/event-source-mappings/:mappingId', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const input = await jsonBody<{batchSize?: unknown; enabled?: unknown}>(c)
return c.json(await svc(c).updateLambdaEventSourceMapping(cloud, c.req.param('mappingId'), {
batchSize: typeof input.batchSize === 'number' ? input.batchSize : undefined,
enabled: typeof input.enabled === 'boolean' ? input.enabled : undefined,
}))
})
})
app.delete('/:cloud/services/serverless/event-source-mappings/:mappingId', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => { await svc(c).deleteLambdaEventSourceMapping(cloud, c.req.param('mappingId')); return c.json({ok: true}) })
})
app.get('/:cloud/services/:service/resources/:id/collections', async (c) => { app.get('/:cloud/services/:service/resources/:id/collections', async (c) => {
const target = childTarget(c) const target = childTarget(c)
if (!target) return c.json({error: 'Unknown cloud or service'}, 404) if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
+17
View File
@@ -75,6 +75,7 @@ const fakeService = {
createImage: async (_input: CreateAmiInput): Promise<{imageId: string}> => ({imageId: 'ami-0a1b2c3d4e5f6a7b'}), createImage: async (_input: CreateAmiInput): Promise<{imageId: string}> => ({imageId: 'ami-0a1b2c3d4e5f6a7b'}),
deregisterImage: async () => {}, deregisterImage: async () => {},
getConsoleOutput: async (instanceId: string): Promise<Ec2ConsoleOutput> => ({instanceId, output: 'boot log'}), getConsoleOutput: async (instanceId: string): Promise<Ec2ConsoleOutput> => ({instanceId, output: 'boot log'}),
updateInstanceSecurityGroups: async () => {},
listAmis: async (): Promise<Ec2Image[]> => [], listAmis: async (): Promise<Ec2Image[]> => [],
listKeyPairs: async (): Promise<Ec2KeyPair[]> => [], listKeyPairs: async (): Promise<Ec2KeyPair[]> => [],
createKeyPair: async (name: string, _keyType: Ec2KeyPairType = 'rsa'): Promise<Ec2KeyPairMaterial> => ({keyPairId: 'key-0a1b2c3d4e5f6a7b', keyName: name, keyMaterial: '---PEM---'}), 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', () => { describe('POST /amis/:imageId/deregister', () => {
test('deregisters AMI and returns ok', async () => { test('deregisters AMI and returns ok', async () => {
const res = await app.request('/amis/ami-0a1b2c3d4e5f6a7b/deregister', {method: 'POST'}) const res = await app.request('/amis/ami-0a1b2c3d4e5f6a7b/deregister', {method: 'POST'})
+41 -1
View File
@@ -15,6 +15,31 @@ import {ec2Service} from '../services/ec2'
type Ec2Service = typeof ec2Service type Ec2Service = typeof ec2Service
type CreateKeyPairInput = {name: string; keyType?: Ec2KeyPairType} 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 { function isEc2KeyPairType(value: unknown): value is Ec2KeyPairType {
return value === 'rsa' || value === 'ed25519' return value === 'rsa' || value === 'ed25519'
} }
@@ -33,7 +58,9 @@ export function createEc2Router(svc: Ec2Service = ec2Service) {
}) })
app.get('/instances/:instanceId', async (c) => { 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) => { 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) => { app.post('/instances/:instanceId/image', async (c) => {
try { try {
const body = await c.req.json<Omit<CreateAmiInput, 'instanceId'>>() const body = await c.req.json<Omit<CreateAmiInput, 'instanceId'>>()
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test";
import { createMskRouter } from "./msk";
import { type mskService } from "../services/msk";
function createService() {
return {
listClusters: async () => [{ ClusterName: "orders" }],
createCluster: async () => ({ ClusterArn: "arn:cluster:orders" }),
listTopics: async () => [{ name: "orders", partitionCount: 1 }],
createTopic: async () => {},
deleteTopic: async () => {},
produceMessage: async () => [{ topicName: "orders", partition: 0, baseOffset: "0" }],
peekMessages: async () => [{ value: "hello" }],
consumerGroupLag: async () => [{ topic: "orders", totalLag: 0, partitions: [] }],
} as unknown as typeof mskService;
}
describe("msk routes", () => {
test("serves clusters", async () => {
const app = createMskRouter(createService());
const clusters = await app.request("/clusters");
expect(clusters.status).toBe(200);
expect(await clusters.json()).toEqual([{ ClusterName: "orders" }]);
});
test("serves topic, message, and consumer group operations through the API", async () => {
const app = createMskRouter(createService());
const topic = await app.request("/clusters/cluster-arn/topics");
expect(await topic.json()).toEqual([{ name: "orders", partitionCount: 1 }]);
const create = await app.request("/clusters/cluster-arn/topics", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ topic: "events" }),
});
expect(create.status).toBe(201);
const produced = await app.request("/clusters/cluster-arn/topics/orders/messages", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ value: "hello" }),
});
expect(produced.status).toBe(201);
const messages = await app.request("/clusters/cluster-arn/topics/orders/messages?limit=1");
expect(await messages.json()).toEqual([{ value: "hello" }]);
const lag = await app.request("/clusters/cluster-arn/consumer-groups/orders-workers/lag");
expect(await lag.json()).toEqual([{ topic: "orders", totalLag: 0, partitions: [] }]);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { Hono } from "hono";
import {
mskService,
type CreateMskClusterInput,
type CreateTopicInput,
type ProduceMessageInput,
} from "../services/msk";
type MskService = typeof mskService;
export function createMskRouter(service: MskService = mskService) {
const app = new Hono();
app.get("/clusters", async (c) => c.json(await service.listClusters()));
app.post("/clusters", async (c) => c.json(await service.createCluster(await c.req.json<CreateMskClusterInput>()), 201));
app.delete("/clusters/:clusterArn", async (c) => {
await service.deleteCluster(c.req.param("clusterArn"));
return c.json({ state: "DELETING" });
});
app.get("/clusters/:clusterArn/bootstrap-brokers", async (c) => c.json({bootstrapBrokers: await service.getBootstrapBrokers(c.req.param("clusterArn"))}));
app.get("/clusters/:clusterArn/topics", async (c) => c.json(await service.listTopics(c.req.param("clusterArn"))));
app.post("/clusters/:clusterArn/topics", async (c) => c.json(await service.createTopic(c.req.param("clusterArn"), await c.req.json<CreateTopicInput>()), 201));
app.delete("/clusters/:clusterArn/topics/:topic", async (c) => c.json(await service.deleteTopic(c.req.param("clusterArn"), c.req.param("topic"))));
app.post("/clusters/:clusterArn/topics/:topic/messages", async (c) => c.json(await service.produceMessage(c.req.param("clusterArn"), c.req.param("topic"), await c.req.json<ProduceMessageInput>()), 201));
app.get("/clusters/:clusterArn/topics/:topic/messages", async (c) => c.json(await service.peekMessages(c.req.param("clusterArn"), c.req.param("topic"), Number(c.req.query("limit") ?? 50))));
app.get("/clusters/:clusterArn/consumer-groups/:groupId/lag", async (c) => c.json(await service.consumerGroupLag(c.req.param("clusterArn"), c.req.param("groupId"), c.req.query("topic"))));
return app;
}
export default createMskRouter();
@@ -0,0 +1,20 @@
import {describe, expect, test} from "bun:test";
import {createMskConnectRouter} from "./mskConnect";
import {type mskConnectService} from "../services/mskConnect";
describe("MSK Connect routes", () => {
test("passes custom plugin and connector operations through the API", async () => {
const app = createMskConnectRouter({
listCustomPlugins: async () => [{name: "s3-sink"}], createCustomPlugin: async () => ({name: "s3-sink"}), listConnectors: async () => [{connectorName: "archive"}], createConnector: async () => ({connectorArn: "arn:archive"}), describeConnector: async () => ({connectorArn: "arn:archive", connectorState: "RUNNING"}), deleteConnector: async () => ({}), updateConnector: async () => ({connectorState: "UPDATING"}),
} as unknown as typeof mskConnectService);
expect((await app.request("/custom-plugins")).status).toBe(200);
const register = await app.request("/custom-plugins", {method: "POST", headers: {"content-type": "application/json"}, body: JSON.stringify({name: "s3-sink", contentType: "ZIP"})});
expect(register.status).toBe(201);
const create = await app.request("/connectors", {method: "POST", headers: {"content-type": "application/json"}, body: JSON.stringify({connectorName: "archive"})});
expect(create.status).toBe(201);
expect(await (await app.request("/connectors/arn%3Aarchive")).json()).toMatchObject({connectorState: "RUNNING"});
expect((await app.request("/connectors/arn%3Aarchive", {method: "PATCH", headers: {"content-type": "application/json"}, body: JSON.stringify({currentVersion: "1"})})).status).toBe(200);
expect((await app.request("/connectors/arn%3Aarchive", {method: "DELETE"})).status).toBe(200);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { Hono } from "hono";
import { mskConnectService } from "../services/mskConnect";
type MskConnectService = typeof mskConnectService;
export function createMskConnectRouter(service: MskConnectService = mskConnectService) {
const app = new Hono();
app.get("/custom-plugins", async (c) => c.json(await service.listCustomPlugins()));
app.post("/custom-plugins", async (c) => c.json(await service.createCustomPlugin(await c.req.json()), 201));
app.delete("/custom-plugins/:customPluginArn", async (c) => c.json(await service.deleteCustomPlugin(c.req.param("customPluginArn"))));
app.get("/connectors", async (c) => c.json(await service.listConnectors()));
app.post("/connectors", async (c) => c.json(await service.createConnector(await c.req.json()), 201));
app.get("/connectors/:connectorArn", async (c) => c.json(await service.describeConnector(c.req.param("connectorArn"))));
app.delete("/connectors/:connectorArn", async (c) => c.json(await service.deleteConnector(c.req.param("connectorArn"))));
app.patch("/connectors/:connectorArn", async (c) => c.json(await service.updateConnector({
...(await c.req.json()),
connectorArn: c.req.param("connectorArn"),
})));
return app;
}
export default createMskConnectRouter();
+55
View File
@@ -0,0 +1,55 @@
import {Hono} from "hono";
import {
sesService,
type RawEmailInput,
} from "../services/ses";
import type {
CreateConfigurationSetCommandInput,
CreateConfigurationSetEventDestinationCommandInput,
CreateEmailIdentityCommandInput,
CreateEmailTemplateCommandInput,
PutSuppressedDestinationCommandInput,
SendEmailCommandInput,
TestRenderEmailTemplateCommandInput,
UpdateEmailTemplateCommandInput,
} from "@aws-sdk/client-sesv2";
type SesService = typeof sesService;
export function createSesRouter(service: SesService = sesService) {
const app = new Hono();
app.get("/identities", async (c) => c.json(await service.listIdentities()));
app.post("/identities", async (c) => c.json(await service.createIdentity(await c.req.json<CreateEmailIdentityCommandInput>()), 201));
app.delete("/identities/:identity", async (c) => c.json(await service.deleteIdentity(c.req.param("identity"))));
app.post("/emails/simple", async (c) => c.json(await service.sendSimple(await c.req.json<SendEmailCommandInput>()), 201));
app.post("/emails/raw", async (c) => c.json(await service.sendRaw(await c.req.json<RawEmailInput>()), 201));
app.post("/emails/template", async (c) => c.json(await service.sendTemplate(await c.req.json<SendEmailCommandInput>()), 201));
app.get("/suppressions", async (c) => c.json(await service.listSuppressedDestinations()));
app.post("/suppressions", async (c) => c.json(await service.createSuppression(await c.req.json<PutSuppressedDestinationCommandInput>()), 201));
app.delete("/suppressions/:emailAddress", async (c) => c.json(await service.deleteSuppression(c.req.param("emailAddress"))));
app.get("/templates", async (c) => c.json(await service.listTemplates()));
app.get("/templates/:templateName", async (c) => c.json(await service.getTemplate(c.req.param("templateName"))));
app.post("/templates", async (c) => c.json(await service.createTemplate(await c.req.json<CreateEmailTemplateCommandInput>()), 201));
app.put("/templates/:templateName", async (c) => c.json(await service.updateTemplate(c.req.param("templateName"), await c.req.json<Omit<UpdateEmailTemplateCommandInput, "TemplateName">>())));
app.delete("/templates/:templateName", async (c) => c.json(await service.deleteTemplate(c.req.param("templateName"))));
app.post("/templates/test-render", async (c) => c.json(await service.testRenderTemplate(await c.req.json<TestRenderEmailTemplateCommandInput>())));
app.get("/configuration-sets", async (c) => c.json(await service.listConfigurationSets()));
app.post("/configuration-sets", async (c) => c.json(await service.createConfigurationSet(await c.req.json<CreateConfigurationSetCommandInput>()), 201));
app.delete("/configuration-sets/:configurationSetName", async (c) => c.json(await service.deleteConfigurationSet(c.req.param("configurationSetName"))));
app.get("/configuration-sets/:configurationSetName/event-destinations", async (c) => c.json(await service.listEventDestinations(c.req.param("configurationSetName"))));
app.post("/configuration-sets/:configurationSetName/event-destinations", async (c) => c.json(await service.createEventDestination(c.req.param("configurationSetName"), await c.req.json<Omit<CreateConfigurationSetEventDestinationCommandInput, "ConfigurationSetName">>()), 201));
app.delete("/configuration-sets/:configurationSetName/event-destinations/:eventDestinationName", async (c) => c.json(await service.deleteEventDestination(c.req.param("configurationSetName"), c.req.param("eventDestinationName"))));
app.get("/inventory/sns-topics", async (c) => c.json(await service.listSnsTopics()));
app.get("/inventory/event-buses", async (c) => c.json(await service.listEventBuses()));
app.get("/inventory/cloudwatch-metrics", async (c) => c.json(await service.listSesMetrics()));
return app;
}
export default createSesRouter();
@@ -43,6 +43,10 @@ import type {
CloudFormationChangeSetInput, CloudFormationChangeSetInput,
SqsLambdaEventSourceMappingInput, SqsLambdaEventSourceMappingInput,
SqsLambdaEventSourceMappingOverview, SqsLambdaEventSourceMappingOverview,
KinesisPutRecordInput,
KinesisRecord,
LambdaEventSourceMappingInput,
LambdaEventSourceMappingOverview,
SqsMessage, SqsMessage,
SqsMoveTaskInput, SqsMoveTaskInput,
SqsMoveTaskResult, SqsMoveTaskResult,
@@ -439,6 +443,42 @@ export class CloudProxyService {
await adapter.deleteSqsLambdaEventSourceMapping(functionName, mappingId) 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}> { async sendSqsMessage(cloud: CloudProvider, queueId: string, input: SqsSendMessageInput): Promise<{messageId: string}> {
const adapter = this.requireAdapter(cloud, 'messaging') const adapter = this.requireAdapter(cloud, 'messaging')
if (!adapter.sendSqsMessage) throw new NotSupportedError(`SQS message sending is not supported for ${cloud}`) if (!adapter.sendSqsMessage) throw new NotSupportedError(`SQS message sending is not supported for ${cloud}`)
+16
View File
@@ -43,6 +43,7 @@ import {
DisassociateRouteTableCommand, DisassociateRouteTableCommand,
GetConsoleOutputCommand, GetConsoleOutputCommand,
ModifySubnetAttributeCommand, ModifySubnetAttributeCommand,
ModifyInstanceAttributeCommand,
ModifyVpcAttributeCommand, ModifyVpcAttributeCommand,
RebootInstancesCommand, RebootInstancesCommand,
ReleaseAddressCommand, ReleaseAddressCommand,
@@ -140,6 +141,14 @@ export type Ec2Instance = {
securityGroups: Ec2SecurityGroupRef[] securityGroups: Ec2SecurityGroupRef[]
networkInterfaces: Ec2NetworkInterface[] networkInterfaces: Ec2NetworkInterface[]
tags: Ec2Tag[] tags: Ec2Tag[]
connection?: Ec2InstanceConnection
}
export type Ec2InstanceConnection = {
sshHost?: string
sshPort?: number
containerId?: string
containerStatus: 'running' | 'stopped' | 'unavailable'
} }
export type Ec2KeyPair = { 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}> { async createImage(input: CreateAmiInput): Promise<{imageId: string}> {
const res = await client.send(new CreateImageCommand({ const res = await client.send(new CreateImageCommand({
InstanceId: input.instanceId, InstanceId: input.instanceId,
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, test } from "bun:test";
import {
CreateClusterCommand,
GetBootstrapBrokersCommand,
ListClustersCommand,
type KafkaClient,
} from "@aws-sdk/client-kafka";
import { createMskService, type KafkaClientFactory } from "./msk";
function createAwsClient() {
const commands: unknown[] = [];
const client = {
send: async (command: unknown) => {
commands.push(command);
if (command instanceof GetBootstrapBrokersCommand) {
return { BootstrapBrokerString: "broker-1:9092,broker-2:9092" };
}
if (command instanceof ListClustersCommand) {
return { ClusterInfoList: [{ ClusterName: "orders" }] };
}
return { ClusterName: "orders", ClusterArn: "arn:cluster:orders" };
},
} as unknown as KafkaClient;
return { client, commands };
}
function createKafkaFactory() {
const calls: string[] = [];
const factory: KafkaClientFactory = () => ({
admin: () => ({
connect: async () => { calls.push("admin.connect"); },
disconnect: async () => { calls.push("admin.disconnect"); },
fetchTopicMetadata: async () => ({ topics: [{ name: "orders", partitions: [{}, {}] }] }),
createTopics: async () => { calls.push("admin.createTopics"); return true; },
deleteTopics: async () => { calls.push("admin.deleteTopics"); },
fetchTopicOffsets: async () => [{ partition: 0, offset: "12" }],
fetchOffsets: async () => [{ topic: "orders", partitions: [{ partition: 0, offset: "7" }] }],
}),
producer: () => ({
connect: async () => { calls.push("producer.connect"); },
disconnect: async () => { calls.push("producer.disconnect"); },
send: async () => [{ topicName: "orders", partition: 0, baseOffset: "12" }],
}),
consumer: () => ({
connect: async () => { calls.push("consumer.connect"); },
disconnect: async () => { calls.push("consumer.disconnect"); },
subscribe: async () => { calls.push("consumer.subscribe"); },
run: async ({ eachMessage }) => {
await eachMessage({
partition: 0,
message: {
key: Buffer.from("key"),
value: Buffer.from("value"),
headers: { source: Buffer.from("test") },
offset: "12",
timestamp: "123",
},
});
},
stop: async () => { calls.push("consumer.stop"); },
}),
});
return { factory, calls };
}
describe("mskService", () => {
test("lists clusters and creates clusters with the MSK SDK", async () => {
const { client, commands } = createAwsClient();
const service = createMskService(client, createKafkaFactory().factory);
expect(await service.listClusters()).toEqual([{ ClusterName: "orders" }]);
await service.createCluster({
clusterName: "orders",
kafkaVersion: "3.6.x",
numberOfBrokerNodes: 3,
brokerNodeGroupInfo: { InstanceType: "kafka.m5.large", ClientSubnets: ["subnet-1", "subnet-2"] },
});
expect(commands[0]).toBeInstanceOf(ListClustersCommand);
expect(commands[1]).toBeInstanceOf(CreateClusterCommand);
expect((commands[1] as CreateClusterCommand).input).toMatchObject({ ClusterName: "orders" });
});
test("uses server-resolved bootstrap brokers for topic and message operations", async () => {
const { client, commands } = createAwsClient();
const { factory, calls } = createKafkaFactory();
const service = createMskService(client, factory);
expect(await service.listTopics("arn:cluster:orders")).toEqual([{ name: "orders", partitionCount: 2 }]);
await service.createTopic("arn:cluster:orders", { topic: "events", numPartitions: 1, replicationFactor: 1 });
await service.deleteTopic("arn:cluster:orders", "events");
expect(await service.produceMessage("arn:cluster:orders", "orders", { value: "hello" })).toEqual([
{ topicName: "orders", partition: 0, baseOffset: "12" },
]);
expect(commands.filter((command) => command instanceof GetBootstrapBrokersCommand)).toHaveLength(4);
expect(calls).toContain("admin.createTopics");
expect(calls).toContain("producer.disconnect");
});
test("peeks without committing and calculates consumer group lag", async () => {
const { client } = createAwsClient();
const { factory, calls } = createKafkaFactory();
const service = createMskService(client, factory);
expect(await service.peekMessages("arn:cluster:orders", "orders", 1)).toEqual([
{
partition: 0,
offset: "12",
timestamp: "123",
key: "key",
value: "value",
headers: { source: "test" },
},
]);
expect(await service.consumerGroupLag("arn:cluster:orders", "orders-workers")).toEqual([
{
topic: "orders",
partitions: [{ partition: 0, currentOffset: "7", endOffset: "12", lag: 5 }],
totalLag: 5,
},
]);
expect(calls).toContain("consumer.stop");
expect(calls).toContain("consumer.disconnect");
});
});
+273
View File
@@ -0,0 +1,273 @@
import {
CreateClusterCommand,
DeleteClusterCommand,
GetBootstrapBrokersCommand,
KafkaClient,
ListClustersCommand,
type BrokerNodeGroupInfo,
} from "@aws-sdk/client-kafka";
import { Kafka } from "kafkajs";
import { awsClients } from "../aws";
export type CreateMskClusterInput = {
clusterName: string;
kafkaVersion: string;
numberOfBrokerNodes: number;
brokerNodeGroupInfo: BrokerNodeGroupInfo;
tags?: Record<string, string>;
};
export type CreateTopicInput = {
topic: string;
numPartitions?: number;
replicationFactor?: number;
configEntries?: { name: string; value: string }[];
};
export type ProduceMessageInput = {
key?: string;
value: string;
headers?: Record<string, string>;
};
type KafkaAdmin = {
connect(): Promise<void>;
disconnect(): Promise<void>;
fetchTopicMetadata(input?: { topics?: string[] }): Promise<{
topics: { name: string; partitions: unknown[] }[];
}>;
createTopics(input: { topics: CreateTopicInput[] }): Promise<boolean>;
deleteTopics(input: { topics: string[] }): Promise<void>;
fetchTopicOffsets(topic: string): Promise<{ partition: number; offset: string }[]>;
fetchOffsets(input: {
groupId: string;
topics?: string[];
}): Promise<{ topic: string; partitions: { partition: number; offset: string }[] }[]>;
};
type KafkaProducer = {
connect(): Promise<void>;
disconnect(): Promise<void>;
send(input: {
topic: string;
messages: { key?: string; value: string; headers?: Record<string, string> }[];
}): Promise<{ topicName: string; partition: number; baseOffset?: string }[]>;
};
type KafkaConsumer = {
connect(): Promise<void>;
disconnect(): Promise<void>;
subscribe(input: { topic: string; fromBeginning: boolean }): Promise<void>;
run(input: {
autoCommit: boolean;
eachMessage: (payload: {
partition: number;
message: {
key: Buffer | null;
value: Buffer | null;
headers?: Record<string, Buffer>;
offset: string;
timestamp: string;
};
}) => Promise<void>;
}): Promise<void>;
stop(): Promise<void>;
};
export type KafkaClientFactory = (brokers: string[]) => {
admin(): KafkaAdmin;
producer(): KafkaProducer;
consumer(input: { groupId: string }): KafkaConsumer;
};
function createKafkaClient(brokers: string[]) {
return new Kafka({ brokers }) as unknown as ReturnType<KafkaClientFactory>;
}
function splitBootstrapBrokers(bootstrapBrokers: string): string[] {
return bootstrapBrokers.split(",").map((broker) => broker.trim()).filter(Boolean);
}
export function createMskService(
client: KafkaClient = awsClients.kafka,
kafkaClientFactory: KafkaClientFactory = createKafkaClient,
) {
async function brokers(clusterArn: string): Promise<string[]> {
const command = new GetBootstrapBrokersCommand({ ClusterArn: clusterArn });
command.middlewareStack.add(
(next) => async (args) => {
const request = args.request as { headers: Record<string, string> };
request.headers["x-floci-internal-client"] = "true";
return next(args);
},
{ step: "build", name: "flociInternalMskClient" },
);
const response = await client.send(command);
const bootstrapBrokers = response.BootstrapBrokerString ?? response.BootstrapBrokerStringTls;
if (!bootstrapBrokers) {
throw new Error(`MSK cluster ${clusterArn} did not return bootstrap brokers`);
}
return splitBootstrapBrokers(bootstrapBrokers);
}
return {
async listClusters() {
const clusters = [];
let nextToken: string | undefined;
do {
const response = await client.send(new ListClustersCommand({ NextToken: nextToken }));
clusters.push(...(response.ClusterInfoList ?? []));
nextToken = response.NextToken;
} while (nextToken);
return clusters;
},
async createCluster(input: CreateMskClusterInput) {
const response = await client.send(new CreateClusterCommand({
ClusterName: input.clusterName,
KafkaVersion: input.kafkaVersion,
NumberOfBrokerNodes: input.numberOfBrokerNodes,
BrokerNodeGroupInfo: input.brokerNodeGroupInfo,
Tags: input.tags,
}));
return response;
},
async deleteCluster(clusterArn: string) {
return client.send(new DeleteClusterCommand({ ClusterArn: clusterArn }));
},
async getBootstrapBrokers(clusterArn: string) {
const response = await client.send(new GetBootstrapBrokersCommand({ ClusterArn: clusterArn }));
return response.BootstrapBrokerString ?? response.BootstrapBrokerStringTls;
},
async listTopics(clusterArn: string) {
const admin = kafkaClientFactory(await brokers(clusterArn)).admin();
await admin.connect();
try {
const metadata = await admin.fetchTopicMetadata();
return metadata.topics
.filter((topic) => !topic.name.startsWith("__"))
.map((topic) => ({
name: topic.name,
partitionCount: topic.partitions.length,
}));
} finally {
await admin.disconnect();
}
},
async createTopic(clusterArn: string, input: CreateTopicInput) {
const admin = kafkaClientFactory(await brokers(clusterArn)).admin();
await admin.connect();
try {
await admin.createTopics({ topics: [input] });
} finally {
await admin.disconnect();
}
},
async deleteTopic(clusterArn: string, topic: string) {
const admin = kafkaClientFactory(await brokers(clusterArn)).admin();
await admin.connect();
try {
await admin.deleteTopics({ topics: [topic] });
} finally {
await admin.disconnect();
}
},
async produceMessage(clusterArn: string, topic: string, input: ProduceMessageInput) {
const producer = kafkaClientFactory(await brokers(clusterArn)).producer();
await producer.connect();
try {
return await producer.send({ topic, messages: [input] });
} finally {
await producer.disconnect();
}
},
async peekMessages(clusterArn: string, topic: string, limit = 50) {
const consumer = kafkaClientFactory(await brokers(clusterArn)).consumer({
groupId: `floci-ui-peek-${crypto.randomUUID()}`,
});
const messages: { partition: number; offset: string; timestamp: string; key?: string; value?: string; headers: Record<string, string> }[] = [];
await consumer.connect();
try {
await consumer.subscribe({ topic, fromBeginning: true });
await new Promise<void>(async (resolve, reject) => {
const timeout = setTimeout(resolve, 500);
try {
await consumer.run({
autoCommit: false,
eachMessage: async ({ partition, message }) => {
if (messages.length >= limit) {
return;
}
messages.push({
partition,
offset: message.offset,
timestamp: message.timestamp,
key: message.key?.toString(),
value: message.value?.toString(),
headers: Object.fromEntries(
Object.entries(message.headers ?? {}).map(([name, value]) => [name, value.toString()]),
),
});
if (messages.length >= limit) {
clearTimeout(timeout);
resolve();
}
},
});
} catch (error) {
clearTimeout(timeout);
reject(error);
}
});
return messages;
} finally {
await consumer.stop();
await consumer.disconnect();
}
},
async consumerGroupLag(clusterArn: string, groupId: string, topic?: string) {
const admin = kafkaClientFactory(await brokers(clusterArn)).admin();
await admin.connect();
let completed = false;
try {
const offsets = await admin.fetchOffsets({ groupId, topics: topic ? [topic] : undefined });
const lag = await Promise.all(offsets.map(async (topicOffsets) => {
const endOffsets = await admin.fetchTopicOffsets(topicOffsets.topic);
const endOffsetByPartition = new Map(endOffsets.map((offset) => [offset.partition, offset.offset]));
const partitions = topicOffsets.partitions.map((offset) => ({
partition: offset.partition,
currentOffset: offset.offset,
endOffset: endOffsetByPartition.get(offset.partition) ?? "0",
lag: Math.max(0, Number(endOffsetByPartition.get(offset.partition) ?? 0) - Math.max(0, Number(offset.offset))),
}));
return {
topic: topicOffsets.topic,
partitions,
totalLag: partitions.reduce((total, partition) => total + partition.lag, 0),
};
}));
completed = true;
return lag;
} finally {
try {
await admin.disconnect();
} catch (ignored) {
// KafkaJS can close the coordinator socket after returning offsets.
if (!completed) {
throw ignored;
}
}
}
},
};
}
export const mskService = createMskService();
@@ -0,0 +1,32 @@
import {describe, expect, test} from "bun:test";
import {CreateConnectorCommand, CreateCustomPluginCommand, DeleteConnectorCommand, DescribeConnectorCommand, ListConnectorsCommand, ListCustomPluginsCommand, UpdateConnectorCommand, type KafkaConnectClient} from "@aws-sdk/client-kafkaconnect";
import {createMskConnectService} from "./mskConnect";
test("uses Kafka Connect SDK commands with AWS request shapes", async () => {
const commands: unknown[] = [];
const client = {send: async (command: unknown) => {
commands.push(command);
if (command instanceof ListCustomPluginsCommand) return {customPlugins: [{name: "s3-sink"}]};
if (command instanceof ListConnectorsCommand) return {connectors: [{connectorName: "archive"}]};
return {connectorArn: "arn:aws:kafkaconnect:local:connector/archive"};
}} as unknown as KafkaConnectClient;
const service = createMskConnectService(client);
expect(await service.listCustomPlugins()).toEqual([{name: "s3-sink"}]);
expect(await service.listConnectors()).toEqual([{connectorName: "archive"}]);
await service.createCustomPlugin({name: "s3-sink", contentType: "JAR", location: {s3Location: {bucketArn: "arn:aws:s3:::plugins", fileKey: "s3.zip"}}});
await service.createConnector({connectorName: "archive", capacity: {provisionedCapacity: {mcuCount: 1, workerCount: 1}}, connectorConfiguration: {topics: "orders"}, kafkaCluster: {apacheKafkaCluster: {bootstrapServers: "broker:9092", vpc: {securityGroups: ["sg-local"], subnets: ["subnet-local"]}}}, kafkaClusterClientAuthentication: {authenticationType: "NONE"}, kafkaClusterEncryptionInTransit: {encryptionType: "PLAINTEXT"}, kafkaConnectVersion: "3.7.x", plugins: [{customPlugin: {customPluginArn: "arn:plugin", revision: 1}}], serviceExecutionRoleArn: "arn:role"});
await service.describeConnector("arn:connector");
await service.updateConnector({connectorArn: "arn:connector", currentVersion: "1", connectorConfiguration: {topics: "events"}});
await service.deleteConnector("arn:connector");
expect(commands[0]).toBeInstanceOf(ListCustomPluginsCommand);
expect(commands[1]).toBeInstanceOf(ListConnectorsCommand);
expect(commands[2]).toBeInstanceOf(CreateCustomPluginCommand);
expect((commands[2] as CreateCustomPluginCommand).input).toMatchObject({contentType: "JAR", location: {s3Location: {fileKey: "s3.zip"}}});
expect(commands[3]).toBeInstanceOf(CreateConnectorCommand);
expect((commands[3] as CreateConnectorCommand).input).toMatchObject({kafkaClusterClientAuthentication: {authenticationType: "NONE"}, plugins: [{customPlugin: {revision: 1}}]});
expect(commands[4]).toBeInstanceOf(DescribeConnectorCommand);
expect(commands[5]).toBeInstanceOf(UpdateConnectorCommand);
expect(commands[6]).toBeInstanceOf(DeleteConnectorCommand);
});
+67
View File
@@ -0,0 +1,67 @@
import {
CreateConnectorCommand,
CreateCustomPluginCommand,
DeleteConnectorCommand,
DeleteCustomPluginCommand,
DescribeConnectorCommand,
KafkaConnectClient,
ListConnectorsCommand,
ListCustomPluginsCommand,
UpdateConnectorCommand,
type CreateConnectorRequest,
type CreateCustomPluginRequest,
type UpdateConnectorRequest,
} from "@aws-sdk/client-kafkaconnect";
import { awsClients } from "../aws";
export function createMskConnectService(client: KafkaConnectClient = awsClients.kafkaConnect) {
return {
async listCustomPlugins() {
const plugins = [];
let nextToken: string | undefined;
do {
const response = await client.send(new ListCustomPluginsCommand({ nextToken }));
plugins.push(...(response.customPlugins ?? []));
nextToken = response.nextToken;
} while (nextToken);
return plugins;
},
createCustomPlugin(input: CreateCustomPluginRequest) {
return client.send(new CreateCustomPluginCommand(input));
},
deleteCustomPlugin(customPluginArn: string) {
return client.send(new DeleteCustomPluginCommand({ customPluginArn }));
},
async listConnectors() {
const connectors = [];
let nextToken: string | undefined;
do {
const response = await client.send(new ListConnectorsCommand({ nextToken }));
connectors.push(...(response.connectors ?? []));
nextToken = response.nextToken;
} while (nextToken);
return connectors;
},
async describeConnector(connectorArn: string) {
return client.send(new DescribeConnectorCommand({ connectorArn }));
},
createConnector(input: CreateConnectorRequest) {
return client.send(new CreateConnectorCommand(input));
},
deleteConnector(connectorArn: string) {
return client.send(new DeleteConnectorCommand({ connectorArn }));
},
updateConnector(input: UpdateConnectorRequest) {
return client.send(new UpdateConnectorCommand(input));
},
};
}
export const mskConnectService = createMskConnectService();
+95
View File
@@ -0,0 +1,95 @@
import {describe, expect, test} from "bun:test";
import {ListMetricsCommand, type CloudWatchClient} from "@aws-sdk/client-cloudwatch";
import {ListEventBusesCommand, type EventBridgeClient} from "@aws-sdk/client-eventbridge";
import {
CreateConfigurationSetEventDestinationCommand,
CreateEmailIdentityCommand,
DeleteEmailTemplateCommand,
ListSuppressedDestinationsCommand,
SendEmailCommand,
TestRenderEmailTemplateCommand,
type SESv2Client,
} from "@aws-sdk/client-sesv2";
import {ListTopicsCommand, type SNSClient} from "@aws-sdk/client-sns";
import {createSesService} from "./ses";
describe("sesService", () => {
test("uses SESv2 identity, email, template, and event destination command contracts", async () => {
const commands: unknown[] = [];
const sesv2 = {
send: async (command: unknown) => {
commands.push(command);
if (command instanceof ListSuppressedDestinationsCommand) return {SuppressedDestinationSummaries: []};
return {MessageId: "message-1"};
},
} as unknown as SESv2Client;
const service = createSesService(sesv2, {} as SNSClient, {} as EventBridgeClient, {} as CloudWatchClient);
await service.createIdentity({EmailIdentity: "sender@example.test"});
await service.sendSimple({
FromEmailAddress: "sender@example.test",
Destination: {ToAddresses: ["recipient@example.test"]},
Content: {Simple: {Subject: {Data: "Hello"}, Body: {Text: {Data: "Hi"}}}},
});
await service.sendRaw({
FromEmailAddress: "sender@example.test",
Destination: {ToAddresses: ["recipient@example.test"]},
Content: {Raw: {Data: "U3ViamVjdDogSGVsbG8="}},
});
await service.testRenderTemplate({TemplateName: "welcome", TemplateData: "{\"name\":\"Ada\"}"});
await service.createEventDestination("delivery-events", {
EventDestinationName: "to-events",
EventDestination: {Enabled: true, MatchingEventTypes: ["SEND"], EventBridgeDestination: {EventBusArn: "arn:aws:events:us-east-1:000000000000:event-bus/default"}},
});
await service.deleteTemplate("welcome");
expect(commands[0]).toBeInstanceOf(CreateEmailIdentityCommand);
expect((commands[0] as CreateEmailIdentityCommand).input).toEqual({EmailIdentity: "sender@example.test"});
expect(commands[1]).toBeInstanceOf(SendEmailCommand);
expect((commands[2] as SendEmailCommand).input.Content?.Raw?.Data).toEqual(Uint8Array.from([83, 117, 98, 106, 101, 99, 116, 58, 32, 72, 101, 108, 108, 111]));
expect(commands[3]).toBeInstanceOf(TestRenderEmailTemplateCommand);
expect(commands[4]).toBeInstanceOf(CreateConfigurationSetEventDestinationCommand);
expect((commands[4] as CreateConfigurationSetEventDestinationCommand).input).toMatchObject({ConfigurationSetName: "delivery-events", EventDestinationName: "to-events"});
expect(commands[5]).toBeInstanceOf(DeleteEmailTemplateCommand);
});
test("lists live destination inventory with the AWS SDK", async () => {
const snsCommands: unknown[] = [];
const eventBridgeCommands: unknown[] = [];
const cloudWatchCommands: unknown[] = [];
const service = createSesService(
{} as SESv2Client,
{send: async (command: unknown) => {
snsCommands.push(command);
return {Topics: [{TopicArn: "arn:aws:sns:us-east-1:000000000000:delivery"}]};
}} as unknown as SNSClient,
{send: async (command: unknown) => {
eventBridgeCommands.push(command);
return {EventBuses: [{Name: "default"}]};
}} as unknown as EventBridgeClient,
{send: async (command: unknown) => {
cloudWatchCommands.push(command);
return {Metrics: [{MetricName: "Send"}]};
}} as unknown as CloudWatchClient,
);
expect(await service.listSnsTopics()).toEqual([{TopicArn: "arn:aws:sns:us-east-1:000000000000:delivery"}]);
expect(await service.listEventBuses()).toEqual([{Name: "default"}]);
expect(await service.listSesMetrics()).toEqual([{MetricName: "Send"}]);
expect(snsCommands[0]).toBeInstanceOf(ListTopicsCommand);
expect(eventBridgeCommands[0]).toBeInstanceOf(ListEventBusesCommand);
expect((cloudWatchCommands[0] as ListMetricsCommand).input).toEqual({Namespace: "AWS/SES", NextToken: undefined});
});
test("normalizes Floci configuration-set names for the console", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => new Response(JSON.stringify({ConfigurationSets: ["mail-events"]}), {headers: {"content-type": "application/json"}})) as unknown as typeof fetch;
try {
const service = createSesService({} as SESv2Client, {} as SNSClient, {} as EventBridgeClient, {} as CloudWatchClient);
expect(await service.listConfigurationSets()).toEqual([{ConfigurationSetName: "mail-events"}]);
} finally {
globalThis.fetch = originalFetch;
}
});
});
+209
View File
@@ -0,0 +1,209 @@
import {CloudWatchClient, ListMetricsCommand} from "@aws-sdk/client-cloudwatch";
import {EventBridgeClient, ListEventBusesCommand} from "@aws-sdk/client-eventbridge";
import {
CreateConfigurationSetCommand,
CreateConfigurationSetEventDestinationCommand,
CreateEmailIdentityCommand,
CreateEmailTemplateCommand,
DeleteConfigurationSetCommand,
DeleteConfigurationSetEventDestinationCommand,
DeleteEmailIdentityCommand,
DeleteEmailTemplateCommand,
DeleteSuppressedDestinationCommand,
GetConfigurationSetEventDestinationsCommand,
GetEmailTemplateCommand,
ListEmailTemplatesCommand,
ListSuppressedDestinationsCommand,
PutSuppressedDestinationCommand,
SESv2Client,
SendEmailCommand,
TestRenderEmailTemplateCommand,
UpdateEmailTemplateCommand,
type CreateConfigurationSetCommandInput,
type CreateConfigurationSetEventDestinationCommandInput,
type CreateEmailIdentityCommandInput,
type CreateEmailTemplateCommandInput,
type PutSuppressedDestinationCommandInput,
type SendEmailCommandInput,
type TestRenderEmailTemplateCommandInput,
type UpdateEmailTemplateCommandInput,
} from "@aws-sdk/client-sesv2";
import {ListTopicsCommand, SNSClient, type Topic} from "@aws-sdk/client-sns";
import {awsClients} from "../aws";
export type RawEmailInput = Omit<SendEmailCommandInput, "Content"> & {
Content: {Raw: {Data: string}};
};
export function createSesService(
sesv2: SESv2Client = awsClients.sesv2,
sns: SNSClient = awsClients.sns,
eventbridge: EventBridgeClient = awsClients.eventbridge,
cloudwatch: CloudWatchClient = awsClients.cloudwatch,
) {
return {
async listIdentities() {
const identities = [];
let NextToken: string | undefined;
do {
const response = await sesRestRequest<{EmailIdentities?: unknown[]; NextToken?: string}>("/v2/email/identities", NextToken);
identities.push(...(response.EmailIdentities ?? []));
NextToken = response.NextToken;
} while (NextToken);
return identities;
},
createIdentity(input: CreateEmailIdentityCommandInput) {
return sesv2.send(new CreateEmailIdentityCommand(input));
},
deleteIdentity(EmailIdentity: string) {
return sesv2.send(new DeleteEmailIdentityCommand({EmailIdentity}));
},
sendSimple(input: SendEmailCommandInput) {
return sesv2.send(new SendEmailCommand(input));
},
sendRaw(input: RawEmailInput) {
return sesv2.send(new SendEmailCommand({
...input,
Content: {Raw: {Data: Uint8Array.fromBase64(input.Content.Raw.Data)}},
}));
},
sendTemplate(input: SendEmailCommandInput) {
return sesv2.send(new SendEmailCommand(input));
},
async listSuppressedDestinations() {
const destinations = [];
let NextToken: string | undefined;
do {
const response = await sesv2.send(new ListSuppressedDestinationsCommand({NextToken}));
destinations.push(...(response.SuppressedDestinationSummaries ?? []));
NextToken = response.NextToken;
} while (NextToken);
return destinations;
},
createSuppression(input: PutSuppressedDestinationCommandInput) {
return sesv2.send(new PutSuppressedDestinationCommand(input));
},
deleteSuppression(EmailAddress: string) {
return sesv2.send(new DeleteSuppressedDestinationCommand({EmailAddress}));
},
async listTemplates() {
const templates = [];
let NextToken: string | undefined;
do {
const response = await sesv2.send(new ListEmailTemplatesCommand({NextToken}));
templates.push(...(response.TemplatesMetadata ?? []));
NextToken = response.NextToken;
} while (NextToken);
return templates;
},
createTemplate(input: CreateEmailTemplateCommandInput) {
return sesv2.send(new CreateEmailTemplateCommand(input));
},
updateTemplate(TemplateName: string, input: Omit<UpdateEmailTemplateCommandInput, "TemplateName">) {
return sesv2.send(new UpdateEmailTemplateCommand({...input, TemplateName}));
},
deleteTemplate(TemplateName: string) {
return sesv2.send(new DeleteEmailTemplateCommand({TemplateName}));
},
getTemplate(TemplateName: string) {
return sesv2.send(new GetEmailTemplateCommand({TemplateName}));
},
testRenderTemplate(input: TestRenderEmailTemplateCommandInput) {
return sesv2.send(new TestRenderEmailTemplateCommand(input));
},
async listConfigurationSets() {
const configurationSets = [];
let NextToken: string | undefined;
do {
const response = await sesRestRequest<{ConfigurationSets?: unknown[]; NextToken?: string}>("/v2/email/configuration-sets", NextToken);
configurationSets.push(...(response.ConfigurationSets ?? []).map((configurationSet) => typeof configurationSet === "string" ? {ConfigurationSetName: configurationSet} : configurationSet));
NextToken = response.NextToken;
} while (NextToken);
return configurationSets;
},
createConfigurationSet(input: CreateConfigurationSetCommandInput) {
return sesv2.send(new CreateConfigurationSetCommand(input));
},
deleteConfigurationSet(ConfigurationSetName: string) {
return sesv2.send(new DeleteConfigurationSetCommand({ConfigurationSetName}));
},
async listEventDestinations(ConfigurationSetName: string) {
const response = await sesv2.send(new GetConfigurationSetEventDestinationsCommand({ConfigurationSetName}));
return response.EventDestinations ?? [];
},
createEventDestination(ConfigurationSetName: string, input: Omit<CreateConfigurationSetEventDestinationCommandInput, "ConfigurationSetName">) {
return sesv2.send(new CreateConfigurationSetEventDestinationCommand({...input, ConfigurationSetName}));
},
deleteEventDestination(ConfigurationSetName: string, EventDestinationName: string) {
return sesv2.send(new DeleteConfigurationSetEventDestinationCommand({ConfigurationSetName, EventDestinationName}));
},
async listSnsTopics() {
const topics: Topic[] = [];
let NextToken: string | undefined;
do {
const response = await sns.send(new ListTopicsCommand({NextToken}));
topics.push(...(response.Topics ?? []));
NextToken = response.NextToken;
} while (NextToken);
return topics;
},
async listEventBuses() {
const eventBuses = [];
let NextToken: string | undefined;
do {
const response = await eventbridge.send(new ListEventBusesCommand({NextToken}));
eventBuses.push(...(response.EventBuses ?? []));
NextToken = response.NextToken;
} while (NextToken);
return eventBuses;
},
async listSesMetrics() {
const metrics = [];
let NextToken: string | undefined;
do {
const response = await cloudwatch.send(new ListMetricsCommand({Namespace: "AWS/SES", NextToken}));
metrics.push(...(response.Metrics ?? []));
NextToken = response.NextToken;
} while (NextToken);
return metrics;
},
};
}
export const sesService = createSesService();
async function sesRestRequest<T>(path: string, nextToken?: string): Promise<T> {
const endpoint = process.env.FLOCI_ENDPOINT ?? "http://localhost:4566";
const url = new URL(path, endpoint);
if (nextToken) {
url.searchParams.set("NextToken", nextToken);
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`SES REST request failed with HTTP ${response.status}`);
}
return response.json() as Promise<T>;
}
+14
View File
@@ -59,6 +59,12 @@ export const apiEndpointKeys = {
createSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.create", createSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.create",
listSqsLambdaEventSourceMappings: "clouds.services.resources.serverless.sqs-event-source-mappings.list", 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", startWorkflowExecution: "clouds.services.resources.workflows.execution.start",
listWorkflowExecutions: "clouds.services.resources.workflows.executions.list", listWorkflowExecutions: "clouds.services.resources.workflows.executions.list",
describeWorkflowExecution: "clouds.services.resources.workflows.execution.describe", describeWorkflowExecution: "clouds.services.resources.workflows.execution.describe",
@@ -219,6 +225,7 @@ export const apiEndpointKeys = {
reboot: "aws.ec2.instances.reboot", reboot: "aws.ec2.instances.reboot",
terminate: "aws.ec2.instances.terminate", terminate: "aws.ec2.instances.terminate",
tags: "aws.ec2.instances.tags", tags: "aws.ec2.instances.tags",
securityGroups: "aws.ec2.instances.securityGroups",
createImage: "aws.ec2.instances.create-image", createImage: "aws.ec2.instances.create-image",
console: "aws.ec2.instances.console", console: "aws.ec2.instances.console",
}, },
@@ -547,6 +554,12 @@ export const endpointRegistry: EndpointRegistry = new Map([
method: "DELETE", 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, apiEndpointKeys.clouds.resources.startWorkflowExecution,
{ path: "/clouds/:cloud/services/workflows/resources/:id/executions", method: "POST" }, { 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.reboot, { path: "/ec2/instances/:instanceId/reboot", method: "POST" }],
[apiEndpointKeys.aws.ec2.instances.terminate,{ path: "/ec2/instances/:instanceId/terminate", 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.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.createImage, { path: "/ec2/instances/:instanceId/image", method: "POST" }],
[apiEndpointKeys.aws.ec2.instances.console, { path: "/ec2/instances/:instanceId/console", method: "GET" }], [apiEndpointKeys.aws.ec2.instances.console, { path: "/ec2/instances/:instanceId/console", method: "GET" }],
[apiEndpointKeys.aws.ec2.amis.list, { path: "/ec2/amis", method: "GET" }], [apiEndpointKeys.aws.ec2.amis.list, { path: "/ec2/amis", method: "GET" }],
+16
View File
@@ -75,6 +75,14 @@ export type Ec2Instance = {
securityGroups: Ec2SecurityGroupRef[]; securityGroups: Ec2SecurityGroupRef[];
networkInterfaces: Ec2NetworkInterface[]; networkInterfaces: Ec2NetworkInterface[];
tags: Ec2Tag[]; tags: Ec2Tag[];
connection?: Ec2InstanceConnection;
};
export type Ec2InstanceConnection = {
sshHost?: string;
sshPort?: number;
containerId?: string;
containerStatus: 'running' | 'stopped' | 'unavailable';
}; };
export type Ec2KeyPair = { 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 }> { export async function createEc2Ami(instanceId: string, input: CreateAmiInput): Promise<{ imageId: string }> {
const res = await apiClient.call<{ imageId: string }>( const res = await apiClient.call<{ imageId: string }>(
apiEndpointKeys.aws.ec2.instances.createImage, 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; 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( export async function createApiGatewayLambdaRoute(
cloud: CloudProvider, cloud: CloudProvider,
apiId: string, apiId: string,
+20
View File
@@ -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";
}
+31
View File
@@ -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 }
@@ -158,6 +158,7 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
const baseInvokeUrl = endpoint ? `${endpoint}/execute-api/${resource.id}/${stage}` : null const baseInvokeUrl = endpoint ? `${endpoint}/execute-api/${resource.id}/${stage}` : null
return ( return (
<div className="apigateway-workspace">
<section className="table-panel"> <section className="table-panel">
<div className="dynamic-stage-header"> <div className="dynamic-stage-header">
<div> <div>
@@ -165,14 +166,6 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
<h3><Link2 size={15}/> Connect Lambda</h3> <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> <p className="muted compact-text">Creates and deploys one Lambda proxy route to the selected REST API.</p>
</div> </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>} {functionName && <a className="button" href={`/cloud-explorer/aws/logs?resource=${encodeURIComponent(`/aws/lambda/${functionName}`)}`}><ScrollText size={13}/> Open Lambda logs</a>}
</div> </div>
<div className="resource-create-inline"> <div className="resource-create-inline">
@@ -300,7 +293,8 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
)} )}
{corsProxyMethods.length > 0 && <ProxyCorsWarning methods={corsProxyMethods} origin={corsOrigin} headers={corsHeaders}/>} {corsProxyMethods.length > 0 && <ProxyCorsWarning methods={corsProxyMethods} origin={corsOrigin} headers={corsHeaders}/>}
</div> </div>
<div className="apigateway-test-panel"> </section>
<section className="table-panel apigateway-test-panel">
<div className="dynamic-stage-header"> <div className="dynamic-stage-header">
<div> <div>
<p className="eyebrow">Method Test</p> <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>} ) : <p className="muted compact-text">Send a request to inspect the API response.</p>}
</div> </div>
</div> </div>
</div> {(testMutation.data || methodTestMutation.data) && functionName && <LogsQueryPanel
cloud={cloud}
logGroupName={`/aws/lambda/${functionName}`}
runtimeReachable={runtimeReachable}
autoRunKey={logsRefreshKey}
/>}
</section> </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 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 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 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]) useEffect(() => { setSelectedId(undefined); setStartOpen(false); setOverrides([]); setBuildspecOverride(defaultBuildspec(projectSourceType)) }, [resource?.id, projectSourceType])
if (!resource) return null if (!resource) return null
const ecrRepository = typeof resource.metadata.ecrRepository === 'string' ? resource.metadata.ecrRepository : undefined const ecrRepository = typeof resource.metadata.ecrRepository === 'string' ? resource.metadata.ecrRepository : undefined
@@ -28,7 +28,7 @@ export function CodeBuildPanel({resource, runtimeReachable}: CodeBuildPanelProps
{startOpen && ( {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()}/> <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>} {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> <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}/>} {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) } 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 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 defaultBuildspec(sourceType?: string): string { return sourceType === 'S3' ? '' : DEFAULT_BUILDSPEC }
function active(status?: string): boolean { return status === 'IN_PROGRESS' } 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 } interface CreateProps { isSubmitting: boolean; submitError: string | null; onCancel: () => void; onSubmit: (values: Record<string, unknown>) => void }
export function CodeDeployCreatePanel({isSubmitting, submitError, onCancel, onSubmit}: CreateProps) { export function CodeDeployCreatePanel({isSubmitting, submitError, onCancel, onSubmit}: CreateProps) {
const [computePlatform, setComputePlatform] = useState<'Lambda' | 'ECS'>('Lambda')
const [applicationName, setApplicationName] = useState('') const [applicationName, setApplicationName] = useState('')
const [deploymentGroupName, setDeploymentGroupName] = useState('') const [deploymentGroupName, setDeploymentGroupName] = useState('')
const [serviceRoleArn, setServiceRoleArn] = 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 [deploymentConfig, setDeploymentConfig] = useState('AllAtOnce')
const [rollbackOnFailure, setRollbackOnFailure] = useState(true) const [rollbackOnFailure, setRollbackOnFailure] = useState(true)
return <form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); onSubmit({applicationName, deploymentGroupName, serviceRoleArn, deploymentConfig, rollbackOnFailure}) }}> const ecs = computePlatform === 'ECS'
<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> 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>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>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>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> <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>} {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> <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 if (!resource) return null
const ecsServices = Array.isArray(resource.metadata.ecsServices) ? resource.metadata.ecsServices as Array<{clusterName?: string; serviceName?: string}> : [] const ecsServices = Array.isArray(resource.metadata.ecsServices) ? resource.metadata.ecsServices as Array<{clusterName?: string; serviceName?: string}> : []
return <section className="table-panel"> 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() }}> {!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>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> <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]}) }}) 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 if (!resource) return null
return <section className="table-panel"> 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>} {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>} {(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> <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 { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { CopyValueButton } from './CopyValueButton'
import { import {
AlertTriangle, AlertTriangle,
HardDrive, HardDrive,
@@ -32,10 +33,12 @@ import {
startEc2Instance, startEc2Instance,
stopEc2Instance, stopEc2Instance,
terminateEc2Instance, terminateEc2Instance,
updateEc2InstanceSecurityGroups,
updateEc2InstanceTags, updateEc2InstanceTags,
type Ec2Image, type Ec2Image,
type Ec2KeyPair, type Ec2KeyPair,
type Ec2SecurityGroup, type Ec2SecurityGroup,
type Ec2SecurityGroupRef,
type Ec2Subnet, type Ec2Subnet,
type Ec2Tag, type Ec2Tag,
type Ec2Vpc, 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) ──────────────────────────────────────────────── // ─── Create AMI modal (inline) ────────────────────────────────────────────────
function CreateAmiModal({ instanceId, instanceName, onClose, onCreated }: { function CreateAmiModal({ instanceId, instanceName, onClose, onCreated }: {
@@ -341,7 +380,7 @@ function DeregisterAmiConfirm({ imageId, onConfirm, onCancel, isPending }: {
function InstancePanel({ instanceId }: { instanceId: string }) { function InstancePanel({ instanceId }: { instanceId: string }) {
const qc = useQueryClient() 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 [showAmiModal, setShowAmiModal] = useState(false)
const [showConsoleModal, setShowConsoleModal] = useState(false) const [showConsoleModal, setShowConsoleModal] = useState(false)
const [terminateStep, setTerminateStep] = useState<'idle' | 'confirm'>('idle') const [terminateStep, setTerminateStep] = useState<'idle' | 'confirm'>('idle')
@@ -510,6 +549,18 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
> >
Details Details
</button> </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 <button
className={`sns-tab${tab === 'tags' ? ' active' : ''}`} className={`sns-tab${tab === 'tags' ? ' active' : ''}`}
onClick={() => setTab('tags')} onClick={() => setTab('tags')}
@@ -602,10 +653,38 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
</div> </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' && ( {tab === 'tags' && (
<InlineTagEditor instanceId={instanceId} initialTags={inst.tags} /> <InlineTagEditor instanceId={instanceId} initialTags={inst.tags} />
)} )}
{tab === 'securityGroups' && (
<InlineSecurityGroupEditor instanceId={instanceId} vpcId={inst.vpcId} initialGroups={inst.securityGroups}/>
)}
{tab === 'console' && ( {tab === 'console' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}> <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"}]'}) 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), getInitialFormValues(activeFields, initialValues),
) )
const [errors, setErrors] = useState<Record<string, string>>({}) 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>) { function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
const nextErrors = validateValues(activeFields, values) const nextErrors = validateValues(visibleFields, values)
setErrors(nextErrors) setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return if (Object.keys(nextErrors).length > 0) return
onSubmit(values) onSubmit(values)
@@ -41,7 +42,7 @@ export function DynamicFormRenderer({
return ( return (
<form className="dynamic-form" onSubmit={submit} noValidate> <form className="dynamic-form" onSubmit={submit} noValidate>
{activeFields.map((field) => ( {visibleFields.map((field) => (
<FieldRow <FieldRow
key={field.name} key={field.name}
field={field} field={field}
@@ -65,9 +65,13 @@ import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPane
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger"; import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
import { CodeBuildPanel } from "@/components/CodeBuildPanel"; import { CodeBuildPanel } from "@/components/CodeBuildPanel";
import { CodePipelinePanel } from "@/components/CodePipelinePanel"; import { CodePipelinePanel } from "@/components/CodePipelinePanel";
import { MskPanel } from "@/components/MskPanel";
import { MskConnectPanel } from "@/components/MskConnectPanel";
import { EcsServicePanel } from "@/components/EcsServicePanel"; import { EcsServicePanel } from "@/components/EcsServicePanel";
import {CodeDeployCreatePanel, CodeDeployPanel} from "@/components/CodeDeployPanel"; import {CodeDeployCreatePanel, CodeDeployPanel} from "@/components/CodeDeployPanel";
import {WorkflowCreatePanel} from "@/components/WorkflowCreatePanel"; import {WorkflowCreatePanel} from "@/components/WorkflowCreatePanel";
import {KinesisPanel} from "@/components/KinesisPanel";
import {SesConsolePanel} from '@/components/SesConsolePanel'
import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate"; import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate";
import {workflowStudioPath} from "@/lib/workflowStudio"; import {workflowStudioPath} from "@/lib/workflowStudio";
import {useNavigate, useSearchParams} from "react-router-dom"; import {useNavigate, useSearchParams} from "react-router-dom";
@@ -138,6 +142,7 @@ export function DynamicResourceView({
const schemaQuery = useQuery({ const schemaQuery = useQuery({
queryKey: ["cloud-schema", cloud, service], queryKey: ["cloud-schema", cloud, service],
queryFn: ({ signal }) => getServiceSchema(cloud, service, signal), queryFn: ({ signal }) => getServiceSchema(cloud, service, signal),
enabled: !(cloud === "aws" && (service === "msk" || service === "msk-connect")),
}); });
const resourcesQuery = useQuery({ const resourcesQuery = useQuery({
@@ -496,6 +501,19 @@ export function DynamicResourceView({
const adapterAvailable = serviceAvailability === "available"; 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) { if (schemaQuery.isLoading) {
return ( return (
<div className="dynamic-resource-view"> <div className="dynamic-resource-view">
@@ -600,6 +618,7 @@ export function DynamicResourceView({
className={`resource-workbench${activeSelected && !showDatabaseSnapshots ? " with-inspector" : ""}`} className={`resource-workbench${activeSelected && !showDatabaseSnapshots ? " with-inspector" : ""}`}
> >
<section className="resource-main"> <section className="resource-main">
{cloud === 'aws' && service === 'email' && <SesConsolePanel runtimeReachable={canUseRuntime}/>}
{isAwsDatabase && ( {isAwsDatabase && (
<div className="drawer-tabs" style={{ marginBottom: 12 }}> <div className="drawer-tabs" style={{ marginBottom: 12 }}>
<button <button
@@ -966,6 +985,7 @@ export function DynamicResourceView({
resource={activeSelected} resource={activeSelected}
runtimeReachable={canUseRuntime} runtimeReachable={canUseRuntime}
/> />
<KinesisPanel cloud={cloud} resource={activeSelected} runtimeReachable={canUseRuntime} mode="lambda" />
{cloud === "aws" && activeSelected?.type === "lambda" && ( {cloud === "aws" && activeSelected?.type === "lambda" && (
<LambdaInvocationHistoryPanel cloud={cloud} functionName={activeSelected.name} runtimeReachable={canUseRuntime}/> <LambdaInvocationHistoryPanel cloud={cloud} functionName={activeSelected.name} runtimeReachable={canUseRuntime}/>
)} )}
@@ -1020,6 +1040,7 @@ export function DynamicResourceView({
{service === "codedeploy" && cloud === "aws" && ( {service === "codedeploy" && cloud === "aws" && (
<CodeDeployPanel resource={activeSelected} runtimeReachable={canUseRuntime} /> <CodeDeployPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)} )}
{service === "kinesis" && cloud === "aws" && <KinesisPanel cloud={cloud} resource={activeSelected} runtimeReachable={canUseRuntime} mode="stream" />}
{editingResource && schema.updateFields && ( {editingResource && schema.updateFields && (
<div <div
className="modal-overlay" 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.getByText('Inactive, not running')).toBeInTheDocument()
expect(screen.queryByText('Running 2 of 2')).not.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) { export function EcsServicePanel({resource}: EcsServicePanelProps) {
if (!resource) return null 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 deployments = array<Deployment>(resource.metadata.deployments)
const tasks = array<Task>(resource.metadata.tasks) const tasks = array<Task>(resource.metadata.tasks)
const loadBalancers = array<LoadBalancer>(resource.metadata.loadBalancers) const loadBalancers = array<LoadBalancer>(resource.metadata.loadBalancers)
@@ -16,7 +18,7 @@ export function EcsServicePanel({resource}: EcsServicePanelProps) {
const launchType = String(resource.metadata.launchType ?? 'EC2') const launchType = String(resource.metadata.launchType ?? 'EC2')
const network = object(resource.metadata.networkConfiguration) const network = object(resource.metadata.networkConfiguration)
return <section className="table-panel"> 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>} {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>} {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> : <> {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> </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 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 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(', ') : '-' } 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>
}
+2 -2
View File
@@ -175,11 +175,11 @@ export function Layout() {
<a className="skip-link" href="#main-content">Skip to content</a> <a className="skip-link" href="#main-content">Skip to content</a>
<aside className="sidebar"> <aside className="sidebar">
<div className="sidebar-inner"> <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-logo" src={theCloud} alt="The Cloud"/>
<img className="brand-mark" src={theCloud} alt="" aria-hidden="true"/> <img className="brand-mark" src={theCloud} alt="" aria-hidden="true"/>
<p>The Cloud</p> <p>The Cloud</p>
</div> </NavLink>
<nav className="nav" aria-label="Console"> <nav className="nav" aria-label="Console">
<div className="nav-section"> <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>) 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)') 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.selectOptions(screen.getByLabelText(/default target group/i), 'target-group-arn')
await user.clear(screen.getByLabelText(/listener port/i)) await user.clear(screen.getByLabelText(/listener port/i))
await user.type(screen.getByLabelText(/listener port/i), '8080') 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 {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react' import {useEffect, useState} from 'react'
import {deleteFlatItem, listCloudResources, listFlatItems, putFlatItem} from '@/api/cloudProxyClient' import {deleteFlatItem, listCloudResources, listFlatItems, putFlatItem} from '@/api/cloudProxyClient'
import {CopyValueButton} from '@/components/CopyValueButton'
import {useAccountId} from '@/lib/accountStore' import {useAccountId} from '@/lib/accountStore'
import type {CloudResource} from '@/types/resource' 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>} {error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
{listenersQuery.isLoading && <div className="muted padded">Loading listeners</div>} {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>} {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> </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, allocateEc2ElasticIp,
associateEc2ElasticIp, associateEc2ElasticIp,
attachEc2InternetGateway, attachEc2InternetGateway,
authorizeEc2SecurityGroupEgress,
authorizeEc2SecurityGroupIngress,
createEc2InternetGateway, createEc2InternetGateway,
createEc2KeyPair, createEc2KeyPair,
createEc2Route, createEc2Route,
@@ -39,8 +37,6 @@ import {
modifyEc2SubnetAttribute, modifyEc2SubnetAttribute,
modifyEc2VpcAttribute, modifyEc2VpcAttribute,
releaseEc2ElasticIp, releaseEc2ElasticIp,
revokeEc2SecurityGroupEgress,
revokeEc2SecurityGroupIngress,
type CreateRouteInput, type CreateRouteInput,
type Ec2ElasticIp, type Ec2ElasticIp,
type Ec2InternetGateway, type Ec2InternetGateway,
@@ -55,8 +51,9 @@ import {
type VpcWizardInput, type VpcWizardInput,
type VpcWizardResult, type VpcWizardResult,
} from '@/api/aws/ec2.api' } from '@/api/aws/ec2.api'
import {deleteFlatItem, listFlatItems, putFlatItem} from '@/api/cloudProxyClient'
import type {CloudProvider} from '@/types/cloud' 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' import {calculateSubnetCidrs, isValidCidr, isValidPort} from '@/lib/network'
// ─── Props ──────────────────────────────────────────────────────────────────── // ─── Props ────────────────────────────────────────────────────────────────────
@@ -133,21 +130,13 @@ function nameTag(tags: Array<{key: string; value: string}>): string {
return tags.find((t) => t.key === 'Name')?.value ?? '' return tags.find((t) => t.key === 'Name')?.value ?? ''
} }
function permissionsToRules(perms: Ec2IpPermission[]): EditRule[] { function ingressItemsToRules(items: ChildItem[]): EditRule[] {
const rules: EditRule[] = [] return items.flatMap((item) => {
for (const perm of perms) { const {protocol, fromPort, toPort, cidr} = item.body
for (const cidr of perm.ipRanges) { if (typeof protocol !== 'string' || typeof fromPort !== 'number' || typeof toPort !== 'number' || typeof cidr !== 'string') return []
rules.push({ return [{rid: item.id, protocol, fromPort: String(fromPort), toPort: String(toPort), cidr}]
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 ruleToPermission(r: EditRule) { function ruleToPermission(r: EditRule) {
return { return {
@@ -375,30 +364,33 @@ function CreateSgModal({vpcs, onClose}: {vpcs: Ec2Vpc[]; onClose: () => void}) {
function EditSgRulesModal({sg, onClose}: {sg: Ec2SecurityGroup; onClose: () => void}) { function EditSgRulesModal({sg, onClose}: {sg: Ec2SecurityGroup; onClose: () => void}) {
const qc = useQueryClient() const qc = useQueryClient()
const [inbound, setInbound] = useState<EditRule[]>(() => permissionsToRules(sg.inboundRules)) const queryKey = ['cloud-items', 'aws', 'networking', sg.groupId]
const [outbound, setOutbound] = useState<EditRule[]>(() => permissionsToRules(sg.outboundRules)) const ingressQ = useQuery({
queryKey,
queryFn: ({signal}) => listFlatItems('aws', 'networking', sg.groupId, undefined, signal),
})
const [inbound, setInbound] = useState<EditRule[]>([])
const [err, setErr] = useState('') const [err, setErr] = useState('')
useEffect(() => {
if (ingressQ.data) setInbound(ingressItemsToRules(ingressQ.data.items))
}, [ingressQ.data])
const mut = useMutation({ const mut = useMutation({
mutationFn: async () => { 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 newInKeys = new Set(inbound.map(ruleKey))
const toAddIn = inbound.filter((r) => !origInKeys.has(ruleKey(r))) const toAddIn = inbound.filter((r) => !origInKeys.has(ruleKey(r)))
const toRevokeIn = permissionsToRules(sg.inboundRules).filter((r) => !newInKeys.has(ruleKey(r))) const toRevokeIn = original.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)))
await Promise.all([ await Promise.all([
...toAddIn.map((r) => authorizeEc2SecurityGroupIngress(sg.groupId, ruleToPermission(r))), ...toAddIn.map((r) => putFlatItem('aws', 'networking', sg.groupId, ruleToPermission(r))),
...toRevokeIn.map((r) => revokeEc2SecurityGroupIngress(sg.groupId, ruleToPermission(r))), ...toRevokeIn.map((r) => deleteFlatItem('aws', 'networking', sg.groupId, r.rid)),
...toAddOut.map((r) => authorizeEc2SecurityGroupEgress(sg.groupId, ruleToPermission(r))),
...toRevokeOut.map((r) => revokeEc2SecurityGroupEgress(sg.groupId, ruleToPermission(r))),
]) ])
}, },
onSuccess: () => { onSuccess: () => {
void qc.invalidateQueries({queryKey})
void qc.invalidateQueries({queryKey: ['ec2', 'security-groups']}) void qc.invalidateQueries({queryKey: ['ec2', 'security-groups']})
onClose() onClose()
}, },
@@ -410,23 +402,25 @@ function EditSgRulesModal({sg, onClose}: {sg: Ec2SecurityGroup; onClose: () => v
<div className="create-table-modal sg-rules-edit-modal"> <div className="create-table-modal sg-rules-edit-modal">
<div className="modal-header" style={{display: 'flex', alignItems: 'center', gap: 8}}> <div className="modal-header" style={{display: 'flex', alignItems: 'center', gap: 8}}>
<Shield size={14} style={{color: 'var(--accent)'}}/> <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> <button className="icon-btn" style={{marginLeft: 'auto'}} onClick={onClose} aria-label="Close" title="Close"><X size={13} aria-hidden="true"/></button>
</div> </div>
<div className="modal-section"> <div className="modal-section">
<p className="modal-section-title" style={{marginBottom: 8}}>Inbound rules</p> <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}/> <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}/>
</div> </div>
{err && <p style={{fontSize: 12, color: 'var(--status-error)', margin: '0 0 8px'}}>{err}</p>} {err && <p style={{fontSize: 12, color: 'var(--status-error)', margin: '0 0 8px'}}>{err}</p>}
<div className="modal-footer"> <div className="modal-footer">
<button className="button" onClick={onClose} disabled={mut.isPending}>Cancel</button> <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} {mut.isPending ? <Loader2 size={13}/> : null}
Save rules Save ingress rules
</button> </button>
</div> </div>
</div> </div>
@@ -977,7 +971,7 @@ function SgDetail({sg, vpcs, onEdit, onDelete}: {
<Shield size={14} style={{color: 'var(--accent)'}}/> <Shield size={14} style={{color: 'var(--accent)'}}/>
<h3 style={{marginLeft: 6}}>{sg.groupName}</h3> <h3 style={{marginLeft: 6}}>{sg.groupName}</h3>
<div style={{marginLeft: 'auto', display: 'flex', gap: 6}}> <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> <button className="button compact danger" onClick={onDelete}><Trash2 size={12}/> Delete</button>
</div> </div>
</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 {CopyValueButton} from "@/components/CopyValueButton";
import {rebootCloudResource, startCloudResource, stopCloudResource} from "@/api/cloudProxyClient"; import {rebootCloudResource, startCloudResource, stopCloudResource} from "@/api/cloudProxyClient";
import {cloudQueryKeys} from "@/api/queries/cloudQueries"; import {cloudQueryKeys} from "@/api/queries/cloudQueries";
import {decodeRawMime, htmlMimePart, inlineMimeImages, parseMimeAttachments} from '@/lib/emailMime'
interface ResourceInspectorProps { interface ResourceInspectorProps {
resource?: CloudResource; resource?: CloudResource;
@@ -217,7 +218,12 @@ function EmailInspector({resource}: {resource: CloudResource}) {
const textBody = getStringMetadata(resource.metadata.textBody) const textBody = getStringMetadata(resource.metadata.textBody)
const htmlBody = getStringMetadata(resource.metadata.htmlBody) const htmlBody = getStringMetadata(resource.metadata.htmlBody)
const rawData = getStringMetadata(resource.metadata.rawData) 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 const activeTab = tab === 'preview' && !hasPreview ? (rawData ? 'raw' : 'text') : tab
return ( return (
@@ -239,13 +245,14 @@ function EmailInspector({resource}: {resource: CloudResource}) {
<button className={`drawer-tab ${activeTab === 'preview' ? 'active' : ''}`} disabled={!hasPreview} onClick={() => setTab('preview')}>Preview</button> <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 === '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> </div>
{activeTab === 'preview' && htmlBody ? ( {activeTab === 'preview' && previewHtml ? (
<iframe <iframe
title="Email HTML preview" title="Email HTML preview"
sandbox="" sandbox=""
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
srcDoc={safeEmailDocument(htmlBody)} srcDoc={safeEmailDocument(previewHtml, inlineImages)}
style={{width: '100%', minHeight: 260, border: '1px solid var(--border)', borderRadius: 4, background: '#fff'}} style={{width: '100%', minHeight: 260, border: '1px solid var(--border)', borderRadius: 4, background: '#fff'}}
/> />
) : activeTab === 'preview' ? ( ) : activeTab === 'preview' ? (
@@ -253,15 +260,32 @@ function EmailInspector({resource}: {resource: CloudResource}) {
) : activeTab === 'text' ? ( ) : 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', 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> <pre className="metadata-block" style={{whiteSpace: 'pre-wrap', wordBreak: 'break-all', margin: 0}}>{rawMime ?? 'No raw MIME data captured.'}</pre>
)} )}
</section> </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> </aside>
) )
} }
function safeEmailDocument(html: string): string { function downloadRawEmail(id: string, rawData: string): void {
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>` 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 }) { 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() const user = userEvent.setup(); renderPanel()
await user.click(screen.getByRole('button', {name: 'Poll for messages'})) await user.click(screen.getByRole('button', {name: 'Poll for messages'}))
expect(await screen.findByText('hello')).toBeInTheDocument() expect(await screen.findByText('hello')).toBeInTheDocument()
expect(screen.getByText('hello').closest('pre')).toHaveClass('sqs-message-body')
expect(api.deleteSqsMessage).not.toHaveBeenCalled() expect(api.deleteSqsMessage).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', {name: 'Delete message message-1'})) await user.click(screen.getByRole('button', {name: 'Delete message message-1'}))
await waitFor(() => expect(api.deleteSqsMessage).toHaveBeenCalledWith('aws', 'orders', 'receipt-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 user.click(screen.getByRole('button', {name: 'Confirm purge'}))
await waitFor(() => expect(api.purgeSqsQueue).toHaveBeenCalledWith('aws', 'orders')) 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> </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> <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.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">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> <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>} {error instanceof Error && <p className="error-text compact-text">{error.message}</p>}
</section> </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 { function parseAttributes(value: string): Record<string, SqsMessageAttribute> | undefined {
if (!value.trim()) return undefined if (!value.trim()) return undefined
const parsed = JSON.parse(value) as Record<string, SqsMessageAttribute> 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 sqsIcon from '@/assets/aws-services/sqs.svg'
import stepFunctionsIcon from '@/assets/aws-services/step-functions.svg' import stepFunctionsIcon from '@/assets/aws-services/step-functions.svg'
import vpcIcon from '@/assets/aws-services/vpc.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. * Maps the server's `iconKey` hint to a component.
@@ -87,6 +90,9 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
codebuild: Hammer, codebuild: Hammer,
codepipeline: Workflow, codepipeline: Workflow,
codedeploy: Workflow, codedeploy: Workflow,
kinesis: Radio,
msk: Radio,
'msk-connect': Radio,
} }
const AWS_SERVICE_ICON_ASSETS: Record<string, string> = { const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
@@ -108,6 +114,9 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
loadbalancing: elasticLoadBalancingIcon, loadbalancing: elasticLoadBalancingIcon,
logs: cloudWatchLogsIcon, logs: cloudWatchLogsIcon,
messaging: sqsIcon, messaging: sqsIcon,
kinesis: kinesisIcon,
msk: mskIcon,
'msk-connect': mskConnectIcon,
nosql: dynamoDbIcon, nosql: dynamoDbIcon,
notifications: snsIcon, notifications: snsIcon,
networking: vpcIcon, networking: vpcIcon,
+94 -4
View File
@@ -438,6 +438,8 @@ pre,
gap: 4px; gap: 4px;
padding: 14px 16px; padding: 14px 16px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
color: inherit;
text-decoration: none;
} }
.brand-logo { .brand-logo {
@@ -479,6 +481,44 @@ pre,
text-align: center; 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 { .nav {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -3414,9 +3454,9 @@ pre,
} }
.apigateway-test-panel { .apigateway-test-panel {
border-top: 1px solid var(--border); border-top: 0;
margin-top: 18px; margin-top: 0;
padding-top: 16px; padding-top: 0;
} }
.apigateway-route-explorer { .apigateway-route-explorer {
@@ -3508,6 +3548,44 @@ pre,
font-size: 11px; 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 { .apigateway-route-method {
color: var(--accent); color: var(--accent);
font-size: 11px; font-size: 11px;
@@ -3679,7 +3757,19 @@ pre,
} }
.apigateway-request-flow { .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);
} }
} }
+60
View File
@@ -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 routeCloud = normalizeCloud(params.cloud)
const cloud = routeCloud ?? 'aws' const cloud = routeCloud ?? 'aws'
const service = params.service ?? '' const service = params.service ?? ''
const isAwsMsk = cloud === 'aws' && (service === 'msk' || service === 'msk-connect')
const [infoOpen, setInfoOpen] = useState(false) const [infoOpen, setInfoOpen] = useState(false)
const cloudsQuery = useCloudsQuery() const cloudsQuery = useCloudsQuery()
@@ -27,7 +28,7 @@ export function CloudExplorerPage() {
const schemaQuery = useQuery({ const schemaQuery = useQuery({
queryKey: ['cloud-schema', cloud, service], queryKey: ['cloud-schema', cloud, service],
queryFn: ({signal}) => getServiceSchema(cloud, service, signal), queryFn: ({signal}) => getServiceSchema(cloud, service, signal),
enabled: Boolean(service), enabled: Boolean(service) && !isAwsMsk,
}) })
const selectedService = useMemo( const selectedService = useMemo(
@@ -46,7 +47,7 @@ export function CloudExplorerPage() {
// Never redirect while the catalog is still loading: doing so silently sent // Never redirect while the catalog is still loading: doing so silently sent
// any unknown or slow-loading service to storage. // 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}/> return <UnknownServiceNotice cloud={cloud} service={service} services={servicesQuery.data}/>
} }
@@ -59,7 +60,7 @@ export function CloudExplorerPage() {
: <Cloud size={20}/>} : <Cloud size={20}/>}
<div> <div>
<h1>Cloud Explorer</h1> <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> </div>
<div className="cloud-header-selectors"> <div className="cloud-header-selectors">
+1
View File
@@ -44,6 +44,7 @@ export interface FieldSchema {
type: FieldType type: FieldType
required: boolean required: boolean
requiredWhen?: {field: string; equals: string} requiredWhen?: {field: string; equals: string}
visibleWhen?: {field: string; values: string[]}
description?: string description?: string
group?: string group?: string
span?: boolean span?: boolean
+122 -27
View File
@@ -19,6 +19,9 @@ importers:
'@aws-sdk/client-cloudformation': '@aws-sdk/client-cloudformation':
specifier: ^3.1111.0 specifier: ^3.1111.0
version: 3.1111.0 version: 3.1111.0
'@aws-sdk/client-cloudwatch':
specifier: ^3.1076.0
version: 3.1146.0
'@aws-sdk/client-cloudwatch-logs': '@aws-sdk/client-cloudwatch-logs':
specifier: ^3.1096.0 specifier: ^3.1096.0
version: 3.1131.0 version: 3.1131.0
@@ -55,6 +58,15 @@ importers:
'@aws-sdk/client-iam': '@aws-sdk/client-iam':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1120.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': '@aws-sdk/client-kms':
specifier: ^3.1096.0 specifier: ^3.1096.0
version: 3.1131.0 version: 3.1131.0
@@ -73,6 +85,9 @@ importers:
'@aws-sdk/client-secrets-manager': '@aws-sdk/client-secrets-manager':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1076.0 version: 3.1076.0
'@aws-sdk/client-sesv2':
specifier: ^3.1076.0
version: 3.1146.0
'@aws-sdk/client-sfn': '@aws-sdk/client-sfn':
specifier: ^3.1096.0 specifier: ^3.1096.0
version: 3.1123.0 version: 3.1123.0
@@ -94,6 +109,9 @@ importers:
hono: hono:
specifier: ^4.12.27 specifier: ^4.12.27
version: 4.12.27 version: 4.12.27
kafkajs:
specifier: ^2.2.4
version: 2.2.4
mssql: mssql:
specifier: ^12.7.0 specifier: ^12.7.0
version: 12.7.0 version: 12.7.0
@@ -272,6 +290,10 @@ packages:
resolution: {integrity: sha512-FmV57mQjXtze2y/TCepl0E+4vvHqXCkiAMn24E5ogYC/xEGAYDPupTkzCQdSUmbyW6/tmPtmcOD+6LbUP1AKog==} resolution: {integrity: sha512-FmV57mQjXtze2y/TCepl0E+4vvHqXCkiAMn24E5ogYC/xEGAYDPupTkzCQdSUmbyW6/tmPtmcOD+6LbUP1AKog==}
engines: {node: '>=20.0.0'} 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': '@aws-sdk/client-codebuild@3.1144.0':
resolution: {integrity: sha512-vLy5q+QNeP1mH6jfIATnVCJb+hTwN8RrZR9syYhyDvH6nu+PiGCSfYpf9z7lqHFiGBGsZbP0DJYfYXVRMrq8ag==} resolution: {integrity: sha512-vLy5q+QNeP1mH6jfIATnVCJb+hTwN8RrZR9syYhyDvH6nu+PiGCSfYpf9z7lqHFiGBGsZbP0DJYfYXVRMrq8ag==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -316,6 +338,18 @@ packages:
resolution: {integrity: sha512-w1UNruAHPJ2l6+fNsW3wBYa725ALR1FRQe+rLheeziNK3EmGK6wxTm39dgKyehPmeSXxEQYCg5gcrYq3Ew3CzQ==} resolution: {integrity: sha512-w1UNruAHPJ2l6+fNsW3wBYa725ALR1FRQe+rLheeziNK3EmGK6wxTm39dgKyehPmeSXxEQYCg5gcrYq3Ew3CzQ==}
engines: {node: '>=20.0.0'} 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': '@aws-sdk/client-kms@3.1131.0':
resolution: {integrity: sha512-IQ5pG+aY5bM+1tyduaVa0m2AmPa/G9IZKbtAgtBP0pcM6wRkOlM3QyQEmi2UOoGd6L+ma738yOa6MmH6V3OLxw==} resolution: {integrity: sha512-IQ5pG+aY5bM+1tyduaVa0m2AmPa/G9IZKbtAgtBP0pcM6wRkOlM3QyQEmi2UOoGd6L+ma738yOa6MmH6V3OLxw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -340,6 +374,10 @@ packages:
resolution: {integrity: sha512-N6azFky/nhHaBfLxza6fMnLbOCgqO12StpCgMDVzx43W7phexcWT61r2mHWrVK6VA6TU+Bze12ZQUb017+VWpw==} resolution: {integrity: sha512-N6azFky/nhHaBfLxza6fMnLbOCgqO12StpCgMDVzx43W7phexcWT61r2mHWrVK6VA6TU+Bze12ZQUb017+VWpw==}
engines: {node: '>=20.0.0'} 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': '@aws-sdk/client-sfn@3.1123.0':
resolution: {integrity: sha512-C9O/NfDJCXCyXqMh4qrEQ03K4YLXlJOxvwrpcIB+6GJ9HsxRVjFhS73E1m9uO6d5A6Quk+efTDXfAbKmwVvKlw==} resolution: {integrity: sha512-C9O/NfDJCXCyXqMh4qrEQ03K4YLXlJOxvwrpcIB+6GJ9HsxRVjFhS73E1m9uO6d5A6Quk+efTDXfAbKmwVvKlw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -643,14 +681,6 @@ packages:
resolution: {integrity: sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==} resolution: {integrity: sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==}
engines: {node: '>=20.0.0'} 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': '@aws-sdk/signature-v4-multi-region@3.996.46':
resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==} resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -1174,6 +1204,10 @@ packages:
resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==}
engines: {node: '>=14.0.0'} 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': '@smithy/node-http-handler@4.11.0':
resolution: {integrity: sha512-ssHIZsadPUA3lGdnoByxfnjtb9xPYQLvdfJRLKIwxOoa6tO1suG4sLFSsgd7D/CsvYd8QbBIuKTImuJha5l6aQ==} resolution: {integrity: sha512-ssHIZsadPUA3lGdnoByxfnjtb9xPYQLvdfJRLKIwxOoa6tO1suG4sLFSsgd7D/CsvYd8QbBIuKTImuJha5l6aQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -1804,6 +1838,9 @@ packages:
picomatch: picomatch:
optional: true optional: true
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
file-entry-cache@8.0.0: file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
@@ -1963,6 +2000,10 @@ packages:
jws@4.0.1: jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} 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: keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -2745,6 +2786,18 @@ snapshots:
'@smithy/types': 4.17.2 '@smithy/types': 4.17.2
tslib: 2.8.1 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': '@aws-sdk/client-codebuild@3.1144.0':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.1
@@ -2876,6 +2929,39 @@ snapshots:
'@smithy/types': 4.17.2 '@smithy/types': 4.17.2
tslib: 2.8.1 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': '@aws-sdk/client-kms@3.1131.0':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.0
@@ -2955,6 +3041,18 @@ snapshots:
'@smithy/types': 4.15.1 '@smithy/types': 4.15.1
tslib: 2.8.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': '@aws-sdk/client-sfn@3.1123.0':
dependencies: dependencies:
'@aws-sdk/core': 3.977.9 '@aws-sdk/core': 3.977.9
@@ -3653,7 +3751,7 @@ snapshots:
'@aws-sdk/nested-clients@3.997.28': '@aws-sdk/nested-clients@3.997.28':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@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 '@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0 '@smithy/core': 3.35.0
'@smithy/fetch-http-handler': 5.8.0 '@smithy/fetch-http-handler': 5.8.0
@@ -3664,7 +3762,7 @@ snapshots:
'@aws-sdk/nested-clients@3.997.36': '@aws-sdk/nested-clients@3.997.36':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@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 '@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0 '@smithy/core': 3.35.0
'@smithy/fetch-http-handler': 5.8.0 '@smithy/fetch-http-handler': 5.8.0
@@ -3675,7 +3773,7 @@ snapshots:
'@aws-sdk/nested-clients@3.997.43': '@aws-sdk/nested-clients@3.997.43':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@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 '@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0 '@smithy/core': 3.35.0
'@smithy/fetch-http-handler': 5.8.0 '@smithy/fetch-http-handler': 5.8.0
@@ -3686,7 +3784,7 @@ snapshots:
'@aws-sdk/nested-clients@3.997.44': '@aws-sdk/nested-clients@3.997.44':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@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 '@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0 '@smithy/core': 3.35.0
'@smithy/fetch-http-handler': 5.8.0 '@smithy/fetch-http-handler': 5.8.0
@@ -3697,7 +3795,7 @@ snapshots:
'@aws-sdk/nested-clients@3.997.45': '@aws-sdk/nested-clients@3.997.45':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@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 '@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0 '@smithy/core': 3.35.0
'@smithy/fetch-http-handler': 5.8.0 '@smithy/fetch-http-handler': 5.8.0
@@ -3723,20 +3821,6 @@ snapshots:
'@smithy/types': 4.17.2 '@smithy/types': 4.17.2
tslib: 2.8.1 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': '@aws-sdk/signature-v4-multi-region@3.996.46':
dependencies: dependencies:
'@aws-sdk/types': 3.974.5 '@aws-sdk/types': 3.974.5
@@ -4367,6 +4451,13 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 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': '@smithy/node-http-handler@4.11.0':
dependencies: dependencies:
'@smithy/core': 3.33.3 '@smithy/core': 3.33.3
@@ -5056,6 +5147,8 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.5 picomatch: 4.0.5
fflate@0.8.3: {}
file-entry-cache@8.0.0: file-entry-cache@8.0.0:
dependencies: dependencies:
flat-cache: 4.0.1 flat-cache: 4.0.1
@@ -5216,6 +5309,8 @@ snapshots:
jwa: 2.0.1 jwa: 2.0.1
safe-buffer: 5.2.1 safe-buffer: 5.2.1
kafkajs@2.2.4: {}
keyv@4.5.4: keyv@4.5.4:
dependencies: dependencies:
json-buffer: 3.0.1 json-buffer: 3.0.1