diff --git a/aws-icons-svg/Analytics/MSK Connect Network Logo.svg b/aws-icons-svg/Analytics/MSK Connect Network Logo.svg new file mode 100644 index 0000000..692cd2e --- /dev/null +++ b/aws-icons-svg/Analytics/MSK Connect Network Logo.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/aws-icons-svg/General-Icons/MSK Connect.png b/aws-icons-svg/General-Icons/MSK Connect.png new file mode 100644 index 0000000..5c6d0bb Binary files /dev/null and b/aws-icons-svg/General-Icons/MSK Connect.png differ diff --git a/packages/api/package.json b/packages/api/package.json index 5127049..6a289af 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -13,6 +13,7 @@ "@aws-sdk/client-api-gateway": "^3.1076.0", "@aws-sdk/client-appconfig": "^3.1076.0", "@aws-sdk/client-cloudformation": "^3.1111.0", + "@aws-sdk/client-cloudwatch": "^3.1076.0", "@aws-sdk/client-cloudwatch-logs": "^3.1096.0", "@aws-sdk/client-codebuild": "^3.1076.0", "@aws-sdk/client-codedeploy": "^3.1144.0", @@ -25,12 +26,16 @@ "@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0", "@aws-sdk/client-eventbridge": "^3.1076.0", "@aws-sdk/client-iam": "^3.1076.0", + "@aws-sdk/client-kafka": "^3.1146.0", + "@aws-sdk/client-kafkaconnect": "^3.1146.0", + "@aws-sdk/client-kinesis": "^3.1076.0", "@aws-sdk/client-kms": "^3.1096.0", "@aws-sdk/client-lambda": "^3.1076.0", "@aws-sdk/client-rds": "^3.1076.0", "@aws-sdk/client-rds-data": "^3.1076.0", "@aws-sdk/client-s3": "^3.1076.0", "@aws-sdk/client-secrets-manager": "^3.1076.0", + "@aws-sdk/client-sesv2": "^3.1076.0", "@aws-sdk/client-sfn": "^3.1096.0", "@aws-sdk/client-sns": "^3.1140.0", "@aws-sdk/client-sqs": "^3.1076.0", @@ -38,6 +43,7 @@ "@aws-sdk/util-dynamodb": "^3.996.7", "dotenv": "^17.4.2", "hono": "^4.12.27", + "kafkajs": "^2.2.4", "mssql": "^12.7.0", "pg": "^8.22.0" }, diff --git a/packages/api/src/adapter-aws/AwsCodeBuildAdapter.test.ts b/packages/api/src/adapter-aws/AwsCodeBuildAdapter.test.ts index 62db00c..22f8818 100644 --- a/packages/api/src/adapter-aws/AwsCodeBuildAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsCodeBuildAdapter.test.ts @@ -24,7 +24,7 @@ describe('AwsCodeBuildAdapter', () => { test('creates a privileged Docker-to-ECR project with no artifacts', async () => { const {client, sent} = stub((command) => command instanceof CreateProjectCommand ? {project} : {}) await new AwsCodeBuildAdapter(client).create({values: {name: 'orders', serviceRole: project.serviceRole, buildspec: 'version: 0.2', ecrRepository: 'orders', dockerPrivilegedMode: 'enabled'}}) - expect((sent[0] as CreateProjectCommand).input).toMatchObject({name: 'orders', serviceRole: project.serviceRole, source: {type: 'NO_SOURCE', buildspec: 'version: 0.2'}, artifacts: {type: 'NO_ARTIFACTS'}, environment: {type: 'LINUX_CONTAINER', image: 'aws/codebuild/standard:7.0', computeType: 'BUILD_GENERAL1_SMALL', privilegedMode: true, environmentVariables: [{name: 'ECR_REPOSITORY', value: 'orders'}]}}) + expect((sent[0] as CreateProjectCommand).input).toMatchObject({name: 'orders', serviceRole: project.serviceRole, source: {type: 'NO_SOURCE', buildspec: 'version: 0.2'}, artifacts: {type: 'NO_ARTIFACTS'}, environment: {type: 'LINUX_CONTAINER', image: 'node:20-bookworm', computeType: 'BUILD_GENERAL1_SMALL', privilegedMode: true, environmentVariables: [{name: 'ECR_REPOSITORY', value: 'orders'}]}}) }) test('creates S3 source and artifact contracts with standard CodeBuild fields', async () => { diff --git a/packages/api/src/adapter-aws/AwsCodeBuildAdapter.ts b/packages/api/src/adapter-aws/AwsCodeBuildAdapter.ts index e0ed92a..d7ecfa3 100644 --- a/packages/api/src/adapter-aws/AwsCodeBuildAdapter.ts +++ b/packages/api/src/adapter-aws/AwsCodeBuildAdapter.ts @@ -47,7 +47,7 @@ export class AwsCodeBuildAdapter implements CloudServiceAdapter { async create(input: CreateResourceInput): Promise { const name = required(input.values.name, 'name') const serviceRole = required(input.values.serviceRole, 'serviceRole') - const image = optional(input.values.environmentImage) ?? 'aws/codebuild/standard:7.0' + const image = optional(input.values.environmentImage) ?? 'node:20-bookworm' const computeType = optional(input.values.computeType) ?? ComputeType.BUILD_GENERAL1_SMALL if (computeType !== ComputeType.BUILD_GENERAL1_SMALL) throw new ValidationError('computeType must be BUILD_GENERAL1_SMALL') const sourceType = supportedType(input.values.sourceType, 'sourceType', 'NO_SOURCE') diff --git a/packages/api/src/adapter-aws/AwsCodeDeployAdapter.test.ts b/packages/api/src/adapter-aws/AwsCodeDeployAdapter.test.ts index ceb6200..d4b1aec 100644 --- a/packages/api/src/adapter-aws/AwsCodeDeployAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsCodeDeployAdapter.test.ts @@ -1,9 +1,49 @@ import {describe, expect, test} from 'bun:test' -import {CreateDeploymentCommand, GetDeploymentCommand, StopDeploymentCommand, type CodeDeployClient} from '@aws-sdk/client-codedeploy' +import {CreateApplicationCommand, CreateDeploymentCommand, CreateDeploymentGroupCommand, GetDeploymentCommand, GetDeploymentGroupCommand, ListApplicationsCommand, StopDeploymentCommand, type CodeDeployClient} from '@aws-sdk/client-codedeploy' import type {LambdaClient} from '@aws-sdk/client-lambda' import {AwsCodeDeployAdapter} from './AwsCodeDeployAdapter' describe('AwsCodeDeployAdapter', () => { + test('creates an ECS application and blue-green deployment group with native CodeDeploy inputs', async () => { + const sent: object[] = [] + const client = {async send(command: object) { + sent.push(command) + if (command instanceof ListApplicationsCommand) return {applications: []} + if (command instanceof GetDeploymentGroupCommand) return {deploymentGroupInfo: {deploymentGroupName: 'production', computePlatform: 'ECS', ecsServices: [{clusterName: 'orders', serviceName: 'api'}]}} + return {} + }} as unknown as CodeDeployClient + const adapter = new AwsCodeDeployAdapter(client, {} as LambdaClient) + + await adapter.create({values: {applicationName: 'orders', deploymentGroupName: 'production', computePlatform: 'ECS', serviceRoleArn: 'arn:aws:iam::000000000000:role/CodeDeployRole', clusterName: 'orders', serviceName: 'api', blueTargetGroupName: 'orders-blue', greenTargetGroupName: 'orders-green', productionListenerArn: 'arn:aws:elasticloadbalancing:us-east-1:000000000000:listener/app/orders/123/456', deploymentConfig: 'Canary10Percent5Minutes', rollbackOnFailure: false}}) + + expect((sent.find((item) => item instanceof CreateApplicationCommand) as CreateApplicationCommand).input).toEqual({applicationName: 'orders', computePlatform: 'ECS'}) + expect((sent.find((item) => item instanceof CreateDeploymentGroupCommand) as CreateDeploymentGroupCommand).input).toEqual({ + applicationName: 'orders', + deploymentGroupName: 'production', + serviceRoleArn: 'arn:aws:iam::000000000000:role/CodeDeployRole', + deploymentConfigName: 'CodeDeployDefault.ECSCanary10Percent5Minutes', + deploymentStyle: {deploymentType: 'BLUE_GREEN', deploymentOption: 'WITH_TRAFFIC_CONTROL'}, + autoRollbackConfiguration: {enabled: false, events: ['DEPLOYMENT_FAILURE']}, + ecsServices: [{clusterName: 'orders', serviceName: 'api'}], + loadBalancerInfo: {targetGroupPairInfoList: [{targetGroups: [{name: 'orders-blue'}, {name: 'orders-green'}], prodTrafficRoute: {listenerArns: ['arn:aws:elasticloadbalancing:us-east-1:000000000000:listener/app/orders/123/456']}}]}, + }) + }) + + test('preserves Lambda application and deployment group defaults', async () => { + const sent: object[] = [] + const client = {async send(command: object) { + sent.push(command) + if (command instanceof ListApplicationsCommand) return {applications: []} + if (command instanceof GetDeploymentGroupCommand) return {deploymentGroupInfo: {deploymentGroupName: 'production', computePlatform: 'Lambda'}} + return {} + }} as unknown as CodeDeployClient + + await new AwsCodeDeployAdapter(client, {} as LambdaClient).create({values: {applicationName: 'orders', deploymentGroupName: 'production', serviceRoleArn: 'arn:aws:iam::000000000000:role/CodeDeployRole'}}) + + expect((sent.find((item) => item instanceof CreateApplicationCommand) as CreateApplicationCommand).input.computePlatform).toBe('Lambda') + expect((sent.find((item) => item instanceof CreateDeploymentGroupCommand) as CreateDeploymentGroupCommand).input).toEqual({applicationName: 'orders', deploymentGroupName: 'production', serviceRoleArn: 'arn:aws:iam::000000000000:role/CodeDeployRole', deploymentConfigName: 'CodeDeployDefault.LambdaAllAtOnce', deploymentStyle: {deploymentType: 'BLUE_GREEN', deploymentOption: 'WITH_TRAFFIC_CONTROL'}, autoRollbackConfiguration: {enabled: true, events: ['DEPLOYMENT_FAILURE']}}) + }) + test('creates a native Lambda AppSpec revision with traffic hooks', async () => { const sent: object[] = [] const client = {async send(command: object) { diff --git a/packages/api/src/adapter-aws/AwsCodeDeployAdapter.ts b/packages/api/src/adapter-aws/AwsCodeDeployAdapter.ts index b47c98a..d40b664 100644 --- a/packages/api/src/adapter-aws/AwsCodeDeployAdapter.ts +++ b/packages/api/src/adapter-aws/AwsCodeDeployAdapter.ts @@ -47,15 +47,30 @@ export class AwsCodeDeployAdapter implements CloudServiceAdapter { async create(input: CreateResourceInput): Promise { const applicationName = required(input.values.applicationName, 'applicationName') const deploymentGroupName = required(input.values.deploymentGroupName, 'deploymentGroupName') + const computePlatform = optional(input.values.computePlatform) ?? 'Lambda' + if (computePlatform !== 'Lambda' && computePlatform !== 'ECS') throw new ValidationError('computePlatform must be Lambda or ECS') const applications = await this.client.send(new ListApplicationsCommand({})) - if (!(applications.applications ?? []).includes(applicationName)) await this.client.send(new CreateApplicationCommand({applicationName, computePlatform: 'Lambda'})) + if (!(applications.applications ?? []).includes(applicationName)) await this.client.send(new CreateApplicationCommand({applicationName, computePlatform})) await this.client.send(new CreateDeploymentGroupCommand({ applicationName, deploymentGroupName, serviceRoleArn: required(input.values.serviceRoleArn, 'serviceRoleArn'), - deploymentConfigName: deploymentConfig(input.values.deploymentConfig), + deploymentConfigName: deploymentConfig(input.values.deploymentConfig, computePlatform), deploymentStyle: {deploymentType: 'BLUE_GREEN', deploymentOption: 'WITH_TRAFFIC_CONTROL'}, autoRollbackConfiguration: {enabled: input.values.rollbackOnFailure !== false, events: ['DEPLOYMENT_FAILURE']}, + ...(computePlatform === 'ECS' ? { + ecsServices: [{ + clusterName: required(input.values.clusterName, 'clusterName'), + serviceName: required(input.values.serviceName, 'serviceName'), + }], + loadBalancerInfo: {targetGroupPairInfoList: [{ + targetGroups: [ + {name: required(input.values.blueTargetGroupName, 'blueTargetGroupName')}, + {name: required(input.values.greenTargetGroupName, 'greenTargetGroupName')}, + ], + prodTrafficRoute: {listenerArns: [required(input.values.productionListenerArn, 'productionListenerArn')]}, + }]}, + } : {}), })) return this.groupResource(applicationName, deploymentGroupName) } @@ -117,14 +132,15 @@ export class AwsCodeDeployAdapter implements CloudServiceAdapter { const group = response.deploymentGroupInfo if (!group?.deploymentGroupName) throw new RuntimeError(`CodeDeploy deployment group ${deploymentGroupName} was not found`) const configuration = group.deploymentConfigName - return {id: `${applicationName}/${deploymentGroupName}`, name: deploymentGroupName, cloud: 'aws', service: 'codedeploy', type: 'codedeploy-deployment-group', region: regionFromArn(group.deploymentGroupId), createdAt: null, status: null, metadata: {applicationName, deploymentGroupName, deploymentGroupArn: group.deploymentGroupId, serviceRoleArn: group.serviceRoleArn, deploymentConfigName: configuration, rollbackOnFailure: group.autoRollbackConfiguration?.enabled ?? false, ecsServices: (group.ecsServices ?? []).map((service) => ({clusterName: service.clusterName, serviceName: service.serviceName})), updatedAt: group.lastSuccessfulDeployment?.createTime?.toISOString()}} + const targetGroupPair = group.loadBalancerInfo?.targetGroupPairInfoList?.[0] + return {id: `${applicationName}/${deploymentGroupName}`, name: deploymentGroupName, cloud: 'aws', service: 'codedeploy', type: 'codedeploy-deployment-group', region: regionFromArn(group.deploymentGroupId), createdAt: null, status: null, metadata: {applicationName, deploymentGroupName, deploymentGroupArn: group.deploymentGroupId, computePlatform: group.computePlatform ?? ((group.ecsServices ?? []).length ? 'ECS' : 'Lambda'), serviceRoleArn: group.serviceRoleArn, deploymentConfigName: configuration, rollbackOnFailure: group.autoRollbackConfiguration?.enabled ?? false, ecsServices: (group.ecsServices ?? []).map((service) => ({clusterName: service.clusterName, serviceName: service.serviceName})), targetGroupNames: (targetGroupPair?.targetGroups ?? []).map((targetGroup) => targetGroup.name).filter(Boolean), productionListenerArns: targetGroupPair?.prodTrafficRoute?.listenerArns ?? [], updatedAt: group.lastSuccessfulDeployment?.createTime?.toISOString()}} } } function required(value: unknown, field: string): string { const result = optional(value); if (!result) throw new ValidationError(`${field} is required`); return result } function optional(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined } function splitId(id: string): [string, string] { const separator = id.indexOf('/'); if (separator <= 0 || separator === id.length - 1) throw new ValidationError('Deployment group id must be applicationName/deploymentGroupName'); return [id.slice(0, separator), id.slice(separator + 1)] } -function deploymentConfig(value: unknown): string { const choice = optional(value) ?? 'AllAtOnce'; return `CodeDeployDefault.Lambda${choice}` } +function deploymentConfig(value: unknown, computePlatform: 'Lambda' | 'ECS'): string { const choice = optional(value) ?? 'AllAtOnce'; return `CodeDeployDefault.${computePlatform}${choice}` } function regionFromArn(arn?: string): string | null { return arn?.split(':')[3] || null } function hooksFor(input: {beforeAllowTrafficHook?: string; afterAllowTrafficHook?: string}): Array> { const hooks: Array> = []; 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>}; 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} } } diff --git a/packages/api/src/adapter-aws/AwsEcsAdapter.test.ts b/packages/api/src/adapter-aws/AwsEcsAdapter.test.ts index 17997fb..866a6fa 100644 --- a/packages/api/src/adapter-aws/AwsEcsAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsEcsAdapter.test.ts @@ -1,5 +1,5 @@ import {describe, expect, test} from 'bun:test' -import {CreateServiceCommand, DeleteServiceCommand, DescribeServicesCommand, DescribeTasksCommand, ListClustersCommand, ListServicesCommand, ListTasksCommand, type ECSClient} from '@aws-sdk/client-ecs' +import {CreateClusterCommand, CreateServiceCommand, DeleteServiceCommand, DescribeClustersCommand, DescribeServicesCommand, DescribeTaskDefinitionCommand, DescribeTasksCommand, ListClustersCommand, ListServicesCommand, ListTaskDefinitionsCommand, ListTasksCommand, RegisterTaskDefinitionCommand, type ECSClient} from '@aws-sdk/client-ecs' import {DescribeLoadBalancersCommand, DescribeTargetGroupsCommand, DescribeTargetHealthCommand, type ElasticLoadBalancingV2Client} from '@aws-sdk/client-elastic-load-balancing-v2' import {AwsEcsAdapter} from './AwsEcsAdapter' import {AwsFargateAdapter} from './AwsFargateAdapter' @@ -44,13 +44,59 @@ describe('AwsEcsAdapter', () => { const elb = {async send() { return {} }} as unknown as ElasticLoadBalancingV2Client const adapter = new AwsEcsAdapter(ecs, elb) - await adapter.create({values: {cluster, serviceName: 'orders', taskDefinition: 'orders:1', launchType: 'FARGATE', subnets: 'subnet-1, subnet-2', securityGroups: 'sg-1', assignPublicIp: 'ENABLED'}}) + await adapter.create({values: {cluster, serviceName: 'orders', taskDefinition: 'orders:1', launchType: 'FARGATE', deploymentController: 'CODE_DEPLOY', subnets: 'subnet-1, subnet-2', securityGroups: 'sg-1', assignPublicIp: 'ENABLED', targetGroupArn: 'tg-1', containerName: 'app', containerPort: '8080'}}) await adapter.delete(`${cluster}|orders`) - expect((sent[0] as CreateServiceCommand).input).toMatchObject({cluster, serviceName: 'orders', launchType: 'FARGATE', networkConfiguration: {awsvpcConfiguration: {subnets: ['subnet-1', 'subnet-2'], securityGroups: ['sg-1'], assignPublicIp: 'ENABLED'}}}) + expect((sent[0] as CreateServiceCommand).input).toMatchObject({cluster, serviceName: 'orders', launchType: 'FARGATE', deploymentController: {type: 'CODE_DEPLOY'}, networkConfiguration: {awsvpcConfiguration: {subnets: ['subnet-1', 'subnet-2'], securityGroups: ['sg-1'], assignPublicIp: 'ENABLED'}}, loadBalancers: [{targetGroupArn: 'tg-1', containerName: 'app', containerPort: 8080}]}) expect((sent.at(-1) as DeleteServiceCommand).input).toEqual({cluster, service: 'orders', force: true}) }) + test('creates and lists clusters through ECS APIs', async () => { + const sent: object[] = [] + const clusterArn = 'arn:aws:ecs:us-east-1:000000000000:cluster/orders' + const ecs = {async send(command: object) { + sent.push(command) + if (command instanceof CreateClusterCommand) return {cluster: {clusterName: 'orders', clusterArn, status: 'ACTIVE'}} + if (command instanceof ListClustersCommand) return {clusterArns: [clusterArn]} + if (command instanceof DescribeClustersCommand) return {clusters: [{clusterName: 'orders', clusterArn, status: 'ACTIVE', runningTasksCount: 2}]} + if (command instanceof ListServicesCommand || command instanceof ListTaskDefinitionsCommand) return {} + return {} + }} as unknown as ECSClient + const adapter = new AwsEcsAdapter(ecs, {async send() { return {} }} as unknown as ElasticLoadBalancingV2Client) + + const created = await adapter.create({values: {resourceKind: 'cluster', clusterName: 'orders'}}) + const resources = await adapter.list() + + expect(sent[0]).toBeInstanceOf(CreateClusterCommand) + expect((sent[0] as CreateClusterCommand).input).toEqual({clusterName: 'orders'}) + expect(created).toMatchObject({id: `cluster:${clusterArn}`, type: 'ecs-cluster', name: 'orders'}) + expect(resources).toContainEqual(expect.objectContaining({type: 'ecs-cluster', name: 'orders', metadata: expect.objectContaining({runningTasksCount: 2})})) + }) + + test('registers and lists only awsvpc Fargate task definitions', async () => { + const sent: object[] = [] + const fargateArn = 'arn:aws:ecs:us-east-1:000000000000:task-definition/orders:1' + const ec2Arn = 'arn:aws:ecs:us-east-1:000000000000:task-definition/worker:1' + const fargate = {taskDefinitionArn: fargateArn, family: 'orders', revision: 1, status: 'ACTIVE', cpu: '256', memory: '512', networkMode: 'awsvpc', requiresCompatibilities: ['FARGATE'], containerDefinitions: [{name: 'web', image: 'orders:latest', portMappings: [{containerPort: 8080}]}]} + const ecs = {async send(command: object) { + sent.push(command) + if (command instanceof RegisterTaskDefinitionCommand) return {taskDefinition: fargate} + if (command instanceof ListClustersCommand) return {} + if (command instanceof ListTaskDefinitionsCommand) return {taskDefinitionArns: [fargateArn, ec2Arn]} + if (command instanceof DescribeTaskDefinitionCommand) return {taskDefinition: command.input.taskDefinition === fargateArn ? fargate : {taskDefinitionArn: ec2Arn, family: 'worker', networkMode: 'bridge', requiresCompatibilities: ['EC2']}} + return {} + }} as unknown as ECSClient + const adapter = new AwsEcsAdapter(ecs, {async send() { return {} }} as unknown as ElasticLoadBalancingV2Client) + + const created = await adapter.create({values: {resourceKind: 'taskDefinition', family: 'orders', image: 'orders:latest', containerName: 'web', containerPort: '8080', cpu: '256', memory: '512'}}) + const resources = await adapter.list() + + expect((sent[0] as RegisterTaskDefinitionCommand).input).toEqual({family: 'orders', cpu: '256', memory: '512', networkMode: 'awsvpc', requiresCompatibilities: ['FARGATE'], containerDefinitions: [{name: 'web', image: 'orders:latest', essential: true, portMappings: [{containerPort: 8080, protocol: 'tcp'}]}]}) + expect(created).toMatchObject({type: 'ecs-task-definition', name: 'orders:1', metadata: {image: 'orders:latest', containerPort: 8080}}) + expect(resources).toHaveLength(1) + expect(resources[0]).toMatchObject({id: `task-definition:${fargateArn}`, type: 'ecs-task-definition'}) + }) + test('does not present stale inactive counts as running', async () => { const ecs = {async send(command: object) { if (command instanceof DescribeServicesCommand) return {services: [{serviceName: 'orders', status: 'INACTIVE', desiredCount: 2, runningCount: 2, pendingCount: 1, deployments: [{desiredCount: 2, runningCount: 2}]}]} diff --git a/packages/api/src/adapter-aws/AwsEcsAdapter.ts b/packages/api/src/adapter-aws/AwsEcsAdapter.ts index 5bc9a79..43ca376 100644 --- a/packages/api/src/adapter-aws/AwsEcsAdapter.ts +++ b/packages/api/src/adapter-aws/AwsEcsAdapter.ts @@ -1,9 +1,31 @@ -import {CreateServiceCommand, DeleteServiceCommand, DescribeServicesCommand, DescribeTasksCommand, ListClustersCommand, ListServicesCommand, ListTasksCommand, type ECSClient, type Service} from '@aws-sdk/client-ecs' +import { + CreateClusterCommand, + CreateServiceCommand, + DeleteClusterCommand, + DeleteServiceCommand, + DeregisterTaskDefinitionCommand, + DescribeClustersCommand, + DescribeServicesCommand, + DescribeTaskDefinitionCommand, + DescribeTasksCommand, + ListClustersCommand, + ListServicesCommand, + ListTaskDefinitionsCommand, + ListTasksCommand, + RegisterTaskDefinitionCommand, + type Cluster, + type ECSClient, + type Service, + type TaskDefinition, +} from '@aws-sdk/client-ecs' import {DescribeLoadBalancersCommand, DescribeTargetGroupsCommand, DescribeTargetHealthCommand, type ElasticLoadBalancingV2Client} from '@aws-sdk/client-elastic-load-balancing-v2' import {RuntimeError, ValidationError} from '../cloud-spi/errors' import {awsEcsSchema} from '../cloud-spi/ecsSchema' import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types' +const CLUSTER_PREFIX = 'cluster:' +const TASK_DEFINITION_PREFIX = 'task-definition:' + export class AwsEcsAdapter implements CloudServiceAdapter { readonly cloud = 'aws' as const readonly service = 'ecs' as const @@ -13,33 +35,101 @@ export class AwsEcsAdapter implements CloudServiceAdapter { schema(): ServiceSchema { return awsEcsSchema() } async list(query: ResourceQuery = {}): Promise { - const resources: CloudResource[] = [] - let clusterToken: string | undefined - do { - const clusters = await this.client.send(new ListClustersCommand(clusterToken ? {nextToken: clusterToken} : {})) - for (const cluster of clusters.clusterArns ?? []) { - const services = await this.services(cluster) - resources.push(...await Promise.all(services.map((service) => this.toResource(cluster, service)))) - } - clusterToken = clusters.nextToken - } while (clusterToken) + const [clusterArns, taskDefinitions] = await Promise.all([this.clusterArns(), this.taskDefinitions()]) + const [clusters, services] = await Promise.all([ + this.describeClusters(clusterArns), + Promise.all(clusterArns.map(async (cluster) => Promise.all((await this.services(cluster)).map((service) => this.toServiceResource(cluster, service))))), + ]) + const resources = [ + ...clusters.map(toClusterResource), + ...taskDefinitions.map(toTaskDefinitionResource), + ...services.flat(), + ] const search = query.search?.trim().toLowerCase() - return resources.filter((resource) => !search || resource.name.toLowerCase().includes(search) || String(resource.metadata.cluster).toLowerCase().includes(search)) + return resources.filter((resource) => !search + || resource.name.toLowerCase().includes(search) + || resource.type.toLowerCase().includes(search) + || String(resource.metadata.cluster ?? '').toLowerCase().includes(search)) } async get(id: string): Promise { - const [cluster, serviceName] = splitId(id) + if (id.startsWith(CLUSTER_PREFIX)) { + const cluster = id.slice(CLUSTER_PREFIX.length) + const response = await this.client.send(new DescribeClustersCommand({clusters: [cluster]})) + return response.clusters?.[0] ? toClusterResource(response.clusters[0]) : null + } + if (id.startsWith(TASK_DEFINITION_PREFIX)) { + const taskDefinition = id.slice(TASK_DEFINITION_PREFIX.length) + const response = await this.client.send(new DescribeTaskDefinitionCommand({taskDefinition})) + return response.taskDefinition && isFargateTaskDefinition(response.taskDefinition) + ? toTaskDefinitionResource(response.taskDefinition) + : null + } + const [cluster, serviceName] = splitServiceId(id) if (!cluster || !serviceName) return null const response = await this.client.send(new DescribeServicesCommand({cluster, services: [serviceName]})) const service = response.services?.[0] - return service ? this.toResource(cluster, service) : null + return service ? this.toServiceResource(cluster, service) : null } async create(input: CreateResourceInput): Promise { + 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 { + 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 { + 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 { + 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 { const cluster = required(input.values.cluster, 'cluster') const serviceName = required(input.values.serviceName, 'serviceName') const taskDefinition = required(input.values.taskDefinition, 'taskDefinition') const launchType = oneOf(input.values.launchType, ['EC2', 'FARGATE'], 'launchType') as 'EC2' | 'FARGATE' | undefined ?? 'FARGATE' + const deploymentController = oneOf(input.values.deploymentController, ['ECS', 'CODE_DEPLOY'], 'deploymentController') as 'ECS' | 'CODE_DEPLOY' | undefined ?? 'ECS' const desiredCount = count(input.values.desiredCount) const subnets = ids(input.values.subnets, 'subnets') if (launchType === 'FARGATE' && subnets.length === 0) throw new ValidationError('subnets is required for Fargate awsvpc networking') @@ -55,34 +145,64 @@ export class AwsEcsAdapter implements CloudServiceAdapter { taskDefinition, desiredCount, launchType, + deploymentController: {type: deploymentController}, ...(subnets.length ? {networkConfiguration: {awsvpcConfiguration: {subnets, ...(securityGroups.length ? {securityGroups} : {}), assignPublicIp: input.values.assignPublicIp === true || input.values.assignPublicIp === 'ENABLED' ? 'ENABLED' : 'DISABLED'}}} : {}), ...(targetGroupArn ? {loadBalancers: [{targetGroupArn, containerName, containerPort}]} : {}), })) if (!response.service) throw new RuntimeError(`ECS did not return the created service ${serviceName}`) - return this.toResource(cluster, response.service) + return this.toServiceResource(cluster, response.service) } - async delete(id: string): Promise { - const [cluster, serviceName] = splitId(id) - if (!cluster || !serviceName) throw new ValidationError('ECS service id must be cluster|service') - await this.client.send(new DeleteServiceCommand({cluster, service: serviceName, force: true})) - } - - private async services(cluster: string): Promise { - const result: Service[] = [] + private async clusterArns(): Promise { + const result: string[] = [] let nextToken: string | undefined do { - const listed = await this.client.send(new ListServicesCommand(nextToken ? {cluster, nextToken} : {cluster})) - if (listed.serviceArns?.length) { - const described = await this.client.send(new DescribeServicesCommand({cluster, services: listed.serviceArns})) - result.push(...(described.services ?? [])) - } - nextToken = listed.nextToken + const response = await this.client.send(new ListClustersCommand(nextToken ? {nextToken} : {})) + result.push(...(response.clusterArns ?? [])) + nextToken = response.nextToken } while (nextToken) return result } - private async toResource(cluster: string, service: Service): Promise { + private async describeClusters(clusterArns: string[]): Promise { + 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 { + 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 { + 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 { const name = service.serviceName ?? service.serviceArn ?? '' const inactive = service.status === 'INACTIVE' const [tasks, loadBalancers] = inactive ? [[], []] : await Promise.all([this.tasks(cluster, name), this.loadBalancerHealth(service)]) @@ -91,6 +211,7 @@ export class AwsEcsAdapter implements CloudServiceAdapter { createdAt: service.createdAt?.toISOString() ?? null, status: service.status ?? null, metadata: { cluster, serviceArn: service.serviceArn, launchType: service.launchType ?? 'EC2', taskDefinition: service.taskDefinition, + deploymentController: service.deploymentController?.type ?? 'ECS', desiredCount: inactive ? 0 : service.desiredCount ?? 0, runningCount: inactive ? 0 : service.runningCount ?? 0, pendingCount: inactive ? 0 : service.pendingCount ?? 0, inactive, networkConfiguration: service.networkConfiguration?.awsvpcConfiguration ? {subnets: service.networkConfiguration.awsvpcConfiguration.subnets ?? [], securityGroups: service.networkConfiguration.awsvpcConfiguration.securityGroups ?? [], assignPublicIp: service.networkConfiguration.awsvpcConfiguration.assignPublicIp ?? 'DISABLED'} : null, @@ -122,11 +243,67 @@ export class AwsEcsAdapter implements CloudServiceAdapter { } } +function toClusterResource(cluster: Cluster): CloudResource { + const arn = cluster.clusterArn ?? cluster.clusterName ?? '' + return { + id: `${CLUSTER_PREFIX}${arn}`, + name: cluster.clusterName ?? arn, + cloud: 'aws', + service: 'ecs', + type: 'ecs-cluster', + region: regionFromArn(cluster.clusterArn), + createdAt: null, + status: cluster.status ?? null, + metadata: { + clusterArn: cluster.clusterArn, + registeredContainerInstancesCount: cluster.registeredContainerInstancesCount ?? 0, + runningTasksCount: cluster.runningTasksCount ?? 0, + pendingTasksCount: cluster.pendingTasksCount ?? 0, + activeServicesCount: cluster.activeServicesCount ?? 0, + statistics: cluster.statistics ?? [], + }, + } +} + +function toTaskDefinitionResource(taskDefinition: TaskDefinition): CloudResource { + const arn = taskDefinition.taskDefinitionArn ?? `${taskDefinition.family ?? ''}:${taskDefinition.revision ?? ''}` + const container = taskDefinition.containerDefinitions?.[0] + return { + id: `${TASK_DEFINITION_PREFIX}${arn}`, + name: `${taskDefinition.family ?? arn}:${taskDefinition.revision ?? ''}`, + cloud: 'aws', + service: 'ecs', + type: 'ecs-task-definition', + region: regionFromArn(taskDefinition.taskDefinitionArn), + createdAt: taskDefinition.registeredAt?.toISOString() ?? null, + status: taskDefinition.status ?? null, + metadata: { + taskDefinitionArn: taskDefinition.taskDefinitionArn, + family: taskDefinition.family, + revision: taskDefinition.revision, + cpu: taskDefinition.cpu, + memory: taskDefinition.memory, + networkMode: taskDefinition.networkMode, + requiresCompatibilities: taskDefinition.requiresCompatibilities ?? [], + containerName: container?.name, + image: container?.image, + containerPort: container?.portMappings?.[0]?.containerPort, + containers: (taskDefinition.containerDefinitions ?? []).map((definition) => ({name: definition.name, image: definition.image, cpu: definition.cpu, memory: definition.memory, ports: (definition.portMappings ?? []).map((mapping) => mapping.containerPort)})), + }, + } +} + +function isFargateTaskDefinition(taskDefinition: TaskDefinition): boolean { + return taskDefinition.networkMode === 'awsvpc' && taskDefinition.requiresCompatibilities?.includes('FARGATE') === true +} + function required(value: unknown, field: string): string { const result = optional(value, field); if (!result) throw new ValidationError(`${field} is required`); return result } function optional(value: unknown, field: string): string | undefined { if (value === undefined || value === null || value === '') return undefined; if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`); return value.trim() || undefined } function ids(value: unknown, field: string): string[] { const raw = optional(value, field); return raw ? raw.split(',').map((id) => id.trim()).filter(Boolean) : [] } function count(value: unknown): number { if (value === undefined || value === '') return 1; const result = Number(value); if (!Number.isInteger(result) || result < 0) throw new ValidationError('desiredCount must be a non-negative integer'); return result } +function port(value: unknown): number { const result = optionalPort(value); if (!result) throw new ValidationError('containerPort is required'); return result } function optionalPort(value: unknown): number | undefined { if (value === undefined || value === '') return undefined; const result = Number(value); if (!Number.isInteger(result) || result < 1 || result > 65535) throw new ValidationError('containerPort must be an integer from 1 to 65535'); return result } function oneOf(value: unknown, choices: string[], field: string): string | undefined { const result = optional(value, field); if (result && !choices.includes(result)) throw new ValidationError(`${field} must be one of ${choices.join(', ')}`); return result } -function splitId(id: string): [string, string] { const separator = id.indexOf('|'); return separator > 0 && separator < id.length - 1 ? [id.slice(0, separator), id.slice(separator + 1)] : ['', ''] } +function splitServiceId(id: string): [string, string] { const separator = id.indexOf('|'); return separator > 0 && separator < id.length - 1 ? [id.slice(0, separator), id.slice(separator + 1)] : ['', ''] } function regionFromArn(arn?: string): string | null { return arn?.split(':')[3] || null } +function chunks(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 } diff --git a/packages/api/src/adapter-aws/AwsKinesisAdapter.test.ts b/packages/api/src/adapter-aws/AwsKinesisAdapter.test.ts new file mode 100644 index 0000000..7a93074 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsKinesisAdapter.test.ts @@ -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) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsKinesisAdapter.ts b/packages/api/src/adapter-aws/AwsKinesisAdapter.ts new file mode 100644 index 0000000..b44ac02 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsKinesisAdapter.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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' +} diff --git a/packages/api/src/adapter-aws/AwsNetworkingAdapter.test.ts b/packages/api/src/adapter-aws/AwsNetworkingAdapter.test.ts new file mode 100644 index 0000000..047f1b9 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsNetworkingAdapter.test.ts @@ -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 => [vpc], + listSecurityGroups: async (): Promise => [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) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsNetworkingAdapter.ts b/packages/api/src/adapter-aws/AwsNetworkingAdapter.ts index ade2d5b..e0ad0ce 100644 --- a/packages/api/src/adapter-aws/AwsNetworkingAdapter.ts +++ b/packages/api/src/adapter-aws/AwsNetworkingAdapter.ts @@ -1,10 +1,14 @@ -import {NotSupportedError} from '../cloud-spi/errors' +import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections' +import {NotFoundError, NotSupportedError, ValidationError} from '../cloud-spi/errors' import {awsNetworkingSchema} from '../cloud-spi/networkingSchema' import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types' -import {ec2Service, type Ec2Tag, type Ec2Vpc} from '../services/ec2' +import {ec2Service, type Ec2IpPermission, type Ec2SecurityGroup, type Ec2Tag, type Ec2Vpc, type IpPermissionInput} from '../services/ec2' type Ec2NetworkingServiceShape = { listVpcs(): Promise + listSecurityGroups(vpcId?: string): Promise + authorizeSecurityGroupIngress(groupId: string, permission: IpPermissionInput): Promise + revokeSecurityGroupIngress(groupId: string, permission: IpPermissionInput): Promise } /** @@ -18,8 +22,11 @@ type Ec2NetworkingServiceShape = { export class AwsNetworkingAdapter implements CloudServiceAdapter { readonly cloud = 'aws' as const readonly service = 'networking' as const + readonly items: ItemStoreAdapter - constructor(private readonly service_: Ec2NetworkingServiceShape = ec2Service) {} + constructor(private readonly service_: Ec2NetworkingServiceShape = ec2Service) { + this.items = new SecurityGroupIngressStore(service_) + } schema(): ServiceSchema { return awsNetworkingSchema() @@ -48,6 +55,87 @@ export class AwsNetworkingAdapter implements CloudServiceAdapter { } } +class SecurityGroupIngressStore implements ItemStoreAdapter { + constructor(private readonly service: Ec2NetworkingServiceShape) {} + + async listItems(groupId: string, _page: PageQuery = {}): Promise> { + 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): Promise { + const permission = permissionFromBody(body) + await this.service.authorizeSecurityGroupIngress(groupId, permission) + return permissionToItem(permission) + } + + async deleteItem(groupId: string, itemId: string): Promise { + 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): 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) + } catch (error) { + if (error instanceof ValidationError) throw error + throw new ValidationError('Invalid security-group ingress rule id', {cause: error}) + } +} + function vpcToResource(vpc: Ec2Vpc): CloudResource { return { id: vpc.vpcId, diff --git a/packages/api/src/adapter-aws/AwsServerlessAdapter.test.ts b/packages/api/src/adapter-aws/AwsServerlessAdapter.test.ts index d30ddba..8bf090d 100644 --- a/packages/api/src/adapter-aws/AwsServerlessAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsServerlessAdapter.test.ts @@ -4,7 +4,8 @@ import { CreateFunctionCommand, CreateEventSourceMappingCommand, DeleteFunctionCommand, - DeleteEventSourceMappingCommand, + DeleteEventSourceMappingCommand, + UpdateEventSourceMappingCommand, GetFunctionCommand, InvokeCommand, type LambdaClient, @@ -450,4 +451,27 @@ describe('AwsServerlessAdapter', () => { expect((sent[0] as DeleteEventSourceMappingCommand).input).toEqual({UUID: 'mapping-1'}) }) }) + + describe('generic Lambda event source mappings', () => { + const streamArn = 'arn:aws:kinesis:us-east-1:000000000000:stream/orders' + + test('creates, lists, updates, and deletes a Kinesis mapping through Lambda SDK commands', async () => { + const {client, sent} = stubLambda((command) => { + if (command instanceof CreateEventSourceMappingCommand || command instanceof UpdateEventSourceMappingCommand) { + return {UUID: 'mapping-1', EventSourceArn: streamArn, FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:worker', State: 'Enabled'} + } + if (command instanceof ListEventSourceMappingsCommand) return {EventSourceMappings: [{UUID: 'mapping-1', EventSourceArn: streamArn}]} + return {} + }) + const adapter = new AwsServerlessAdapter(client) + await adapter.createLambdaEventSourceMapping({functionName: 'worker', eventSourceArn: streamArn, batchSize: 100, startingPosition: 'LATEST'}) + await adapter.listLambdaEventSourceMappings({eventSourceArn: streamArn}) + await adapter.updateLambdaEventSourceMapping('mapping-1', {enabled: false}) + await adapter.deleteLambdaEventSourceMapping('mapping-1') + expect((sent[0] as CreateEventSourceMappingCommand).input).toEqual({FunctionName: 'worker', EventSourceArn: streamArn, BatchSize: 100, StartingPosition: 'LATEST'}) + expect((sent[1] as ListEventSourceMappingsCommand).input.EventSourceArn).toBe(streamArn) + expect((sent[2] as UpdateEventSourceMappingCommand).input).toEqual({UUID: 'mapping-1', Enabled: false}) + expect((sent[3] as DeleteEventSourceMappingCommand).input).toEqual({UUID: 'mapping-1'}) + }) + }) }) diff --git a/packages/api/src/adapter-aws/AwsServerlessAdapter.ts b/packages/api/src/adapter-aws/AwsServerlessAdapter.ts index 1f28b9d..f3805fc 100644 --- a/packages/api/src/adapter-aws/AwsServerlessAdapter.ts +++ b/packages/api/src/adapter-aws/AwsServerlessAdapter.ts @@ -5,6 +5,7 @@ import { AddPermissionCommand, DeleteFunctionCommand, DeleteEventSourceMappingCommand, + UpdateEventSourceMappingCommand, GetFunctionCommand, InvokeCommand, type InvokeCommandOutput, @@ -34,6 +35,8 @@ import type { EventBridgeLambdaTriggerOverview, SqsLambdaEventSourceMappingInput, SqsLambdaEventSourceMappingOverview, + LambdaEventSourceMappingInput, + LambdaEventSourceMappingOverview, ServerlessInvokeResult, ServiceSchema, UpdateResourceInput, @@ -385,6 +388,57 @@ export class AwsServerlessAdapter implements CloudServiceAdapter { await this.lambda.send(new DeleteEventSourceMappingCommand({UUID: uuid})) } + async createLambdaEventSourceMapping(input: LambdaEventSourceMappingInput): Promise { + 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 { + 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 { + 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 { + const uuid = stringValue(mappingId) + if (!uuid) throw new ValidationError('mappingId is required') + await this.lambda.send(new DeleteEventSourceMappingCommand({UUID: uuid})) + } + private async listEventBridgeTargets(ruleName: string) { const targets = [] let nextToken: string | undefined @@ -457,6 +511,22 @@ function sqsBatchSize(value: number): number { return value } +function eventSourceBatchSize(value: number, maximum: number): number { + if (!Number.isInteger(value) || value < 1 || value > maximum) throw new ValidationError(`batchSize must be an integer between 1 and ${maximum}`) + return value +} + +function lambdaEventSourceMappingOverview( + mapping: {UUID?: string; FunctionArn?: string; EventSourceArn?: string; BatchSize?: number; StartingPosition?: string; State?: string; StateTransitionReason?: string}, +): LambdaEventSourceMappingOverview { + if (!mapping.UUID || !mapping.EventSourceArn) throw new ValidationError('Lambda event source mapping did not return a UUID and event source ARN') + return { + id: mapping.UUID, uuid: mapping.UUID, functionName: mapping.FunctionArn ? lambdaFunctionName(mapping.FunctionArn) : null, + functionArn: mapping.FunctionArn ?? null, eventSourceArn: mapping.EventSourceArn, batchSize: mapping.BatchSize ?? null, + startingPosition: mapping.StartingPosition ?? null, state: mapping.State ?? null, stateTransitionReason: mapping.StateTransitionReason ?? null, + } +} + function sqsEventSourceMappingOverview( mapping: {UUID?: string; FunctionArn?: string; EventSourceArn?: string; BatchSize?: number; State?: string; StateTransitionReason?: string}, functionName: string, diff --git a/packages/api/src/aws.ts b/packages/api/src/aws.ts index 67fdc18..5fb5a32 100644 --- a/packages/api/src/aws.ts +++ b/packages/api/src/aws.ts @@ -23,6 +23,11 @@ import { CodeBuildClient } from "@aws-sdk/client-codebuild"; import { CodePipelineClient } from "@aws-sdk/client-codepipeline"; import { CodeDeployClient } from "@aws-sdk/client-codedeploy"; import { ECSClient } from "@aws-sdk/client-ecs"; +import { KafkaClient } from "@aws-sdk/client-kafka"; +import { KafkaConnectClient } from "@aws-sdk/client-kafkaconnect"; +import { KinesisClient } from "@aws-sdk/client-kinesis"; +import { SESv2Client } from "@aws-sdk/client-sesv2"; +import { CloudWatchClient } from "@aws-sdk/client-cloudwatch"; const endpoint = process.env.FLOCI_ENDPOINT; const region = process.env.AWS_REGION || "us-east-1"; @@ -77,6 +82,11 @@ export type AwsClients = { codepipeline: CodePipelineClient; codedeploy: CodeDeployClient; ecs: ECSClient; + kafka: KafkaClient; + kafkaConnect: KafkaConnectClient; + kinesis: KinesisClient; + sesv2: SESv2Client; + cloudwatch: CloudWatchClient; }; export type AwsClientName = keyof AwsClients; @@ -115,6 +125,11 @@ function buildClients(accountId: string): AwsClients { codepipeline: new CodePipelineClient(base), codedeploy: new CodeDeployClient(base), ecs: new ECSClient(base), + kafka: new KafkaClient(base), + kafkaConnect: new KafkaConnectClient(base), + kinesis: new KinesisClient(base), + sesv2: new SESv2Client(base), + cloudwatch: new CloudWatchClient(base), }; } @@ -153,3 +168,4 @@ export const dynamodb = awsClients.dynamodb; export const apiGateway = awsClients.apiGateway; export const cloudformation = awsClients.cloudformation; export const ecr = awsClients.ecr; +export const kinesis = awsClients.kinesis; diff --git a/packages/api/src/cloud-spi/codebuildSchema.test.ts b/packages/api/src/cloud-spi/codebuildSchema.test.ts new file mode 100644 index 0000000..05eab56 --- /dev/null +++ b/packages/api/src/cloud-spi/codebuildSchema.test.ts @@ -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') + }) +}) diff --git a/packages/api/src/cloud-spi/codebuildSchema.ts b/packages/api/src/cloud-spi/codebuildSchema.ts index 910893c..4e4c88e 100644 --- a/packages/api/src/cloud-spi/codebuildSchema.ts +++ b/packages/api/src/cloud-spi/codebuildSchema.ts @@ -9,6 +9,8 @@ const projectColumns: TableColumnSchema[] = [ const projectFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}] +export const DEFAULT_DOCKER_TO_ECR_BUILDSPEC = 'version: 0.2\nphases:\n install:\n commands:\n - apt-get update && apt-get install -y --no-install-recommends awscli docker.io\n pre_build:\n commands:\n - REPOSITORY_URI=$(aws --endpoint-url "$AWS_ENDPOINT_URL" ecr describe-repositories --repository-names "$ECR_REPOSITORY" --query "repositories[0].repositoryUri" --output text)\n - ECR_REGISTRY=${REPOSITORY_URI%%/*}\n - aws --endpoint-url "$AWS_ENDPOINT_URL" ecr get-login-password | docker login --username AWS --password-stdin "$ECR_REGISTRY"\n build:\n commands:\n - REPOSITORY_URI=$(aws --endpoint-url "$AWS_ENDPOINT_URL" ecr describe-repositories --repository-names "$ECR_REPOSITORY" --query "repositories[0].repositoryUri" --output text)\n - IMAGE_TAG=build-$CODEBUILD_BUILD_NUMBER\n - docker build -t "$REPOSITORY_URI:$IMAGE_TAG" .\n - docker push "$REPOSITORY_URI:$IMAGE_TAG"\n post_build:\n commands:\n - REPOSITORY_URI=$(aws --endpoint-url "$AWS_ENDPOINT_URL" ecr describe-repositories --repository-names "$ECR_REPOSITORY" --query "repositories[0].repositoryUri" --output text)\n - IMAGE_TAG=build-$CODEBUILD_BUILD_NUMBER\n - printf \'{"ImageURI":"%s"}\' "$REPOSITORY_URI:$IMAGE_TAG" > imageDetail.json\nartifacts:\n files:\n - imageDetail.json\n - taskdef.json\n - appspec.yaml' + /** The console exposes only source and artifact modes the local runtime can execute. */ export function awsCodeBuildSchema(): ServiceSchema { return { @@ -33,8 +35,8 @@ export function awsCodeBuildSchema(): ServiceSchema { }, { name: 'buildspec', label: 'Buildspec', type: 'textarea', required: false, group: 'Source', span: true, - defaultValueWhen: {field: 'sourceType', values: {NO_SOURCE: 'version: 0.2\nphases:\n pre_build:\n commands:\n - AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)\n - REPOSITORY_URI=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$ECR_REPOSITORY\n - aws ecr get-login-password | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com\n build:\n commands:\n - IMAGE_TAG=${CODEBUILD_RESOLVED_SOURCE_VERSION:0:7}\n - docker build -t $REPOSITORY_URI:$IMAGE_TAG .\n - docker push $REPOSITORY_URI:$IMAGE_TAG\n post_build:\n commands:\n - printf \'[{"name":"IMAGE1_NAME","imageUri":"%s"}]\' "$REPOSITORY_URI:$IMAGE_TAG" > imageDetail.json\nartifacts:\n files:\n - imageDetail.json\n - taskdef.json\n - appspec.yaml', S3: ''}}, - description: 'For no source, this is a Docker-to-ECR buildspec. For S3, leave blank to use buildspec.yml from the source, or provide an override.', + defaultValueWhen: {field: 'sourceType', values: {NO_SOURCE: DEFAULT_DOCKER_TO_ECR_BUILDSPEC, S3: ''}}, + description: 'For no source, this is a Floci Node 20 Docker-to-ECR buildspec. For S3, leave blank to use buildspec.yml from the source, or provide an override. Include taskdef.json and appspec.yaml in the source for CodeDeployToECS.', }, { name: 'artifactsType', label: 'Artifact type', type: 'select', required: true, group: 'Artifacts', defaultValue: 'NO_ARTIFACTS', @@ -50,7 +52,7 @@ export function awsCodeBuildSchema(): ServiceSchema { }, { name: 'environmentImage', label: 'Runtime image', type: 'select', required: true, group: 'Environment', - defaultValue: 'aws/codebuild/standard:7.0', + defaultValue: 'node:20-bookworm', options: [ {label: 'CodeBuild Standard 7.0', value: 'aws/codebuild/standard:7.0'}, {label: 'Node.js 20 (Debian)', value: 'node:20-bookworm'}, @@ -68,7 +70,7 @@ export function awsCodeBuildSchema(): ServiceSchema { { name: 'dockerPrivilegedMode', label: 'Docker daemon', type: 'select', required: true, group: 'Docker and ECR', defaultValue: 'enabled', options: [{label: 'Enabled (required for Docker builds)', value: 'enabled'}], - description: 'CodeBuild runs Docker-in-Docker in privileged mode. This console flow targets ECS EC2 only.', + description: 'CodeBuild runs Docker-in-Docker in privileged mode for ECS EC2 and Fargate deployment artifacts.', }, ], actions: ['list', 'create', 'delete', 'inspect'], diff --git a/packages/api/src/cloud-spi/codedeploySchema.ts b/packages/api/src/cloud-spi/codedeploySchema.ts index fb6e0d0..a0961c6 100644 --- a/packages/api/src/cloud-spi/codedeploySchema.ts +++ b/packages/api/src/cloud-spi/codedeploySchema.ts @@ -3,6 +3,7 @@ import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types' const columns: TableColumnSchema[] = [ {name: 'name', label: 'Deployment group'}, {name: 'applicationName', label: 'Application', path: 'metadata.applicationName'}, + {name: 'computePlatform', label: 'Platform', path: 'metadata.computePlatform', format: 'badge'}, {name: 'deploymentConfigName', label: 'Configuration', path: 'metadata.deploymentConfigName', format: 'badge'}, {name: 'updatedAt', label: 'Updated', path: 'metadata.updatedAt', format: 'datetime'}, ] diff --git a/packages/api/src/cloud-spi/codepipelineSchema.test.ts b/packages/api/src/cloud-spi/codepipelineSchema.test.ts new file mode 100644 index 0000000..e3a90e1 --- /dev/null +++ b/packages/api/src/cloud-spi/codepipelineSchema.test.ts @@ -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') + }) +}) diff --git a/packages/api/src/cloud-spi/codepipelineSchema.ts b/packages/api/src/cloud-spi/codepipelineSchema.ts index e640ce3..b15ebe4 100644 --- a/packages/api/src/cloud-spi/codepipelineSchema.ts +++ b/packages/api/src/cloud-spi/codepipelineSchema.ts @@ -22,7 +22,7 @@ export function awsCodePipelineSchema(): ServiceSchema { {name: 'codeDeployDeploymentGroup', label: 'CodeDeploy deployment group', type: 'text', required: true, group: 'ECS deploy'}, {name: 'taskDefinitionPath', label: 'Task definition path', type: 'text', required: true, group: 'ECS deploy', defaultValue: 'taskdef.json'}, {name: 'appSpecPath', label: 'AppSpec path', type: 'text', required: true, group: 'ECS deploy', defaultValue: 'appspec.yaml'}, - {name: 'imageContainerName', label: 'Task definition image placeholder', type: 'text', required: true, group: 'ECS deploy', defaultValue: 'IMAGE1_NAME', description: 'The placeholder in taskdef.json, for example . 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 . BuildOutput must contain imageDetail.json as {"ImageURI":"..."}, plus taskdef.json and appspec.yaml. The deployment group may target an ECS EC2 or Fargate service.'}, ], actions: ['list', 'create', 'delete', 'inspect'], capabilities: {resourceActions: [ diff --git a/packages/api/src/cloud-spi/ecsSchema.ts b/packages/api/src/cloud-spi/ecsSchema.ts index ed56aae..19c5935 100644 --- a/packages/api/src/cloud-spi/ecsSchema.ts +++ b/packages/api/src/cloud-spi/ecsSchema.ts @@ -1,7 +1,8 @@ import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types' const columns: TableColumnSchema[] = [ - {name: 'name', label: 'Service'}, + {name: 'name', label: 'Resource'}, + {name: 'type', label: 'Type', format: 'badge'}, {name: 'status', label: 'Status', format: 'badge'}, {name: 'launchType', label: 'Launch type', path: 'metadata.launchType', format: 'badge'}, {name: 'cluster', label: 'Cluster', path: 'metadata.cluster', format: 'code'}, @@ -16,24 +17,31 @@ export function awsEcsSchema(): ServiceSchema { return { cloud: 'aws', service: 'ecs', displayName: 'Amazon ECS', fields: [ - {name: 'cluster', label: 'Cluster', type: 'text', required: true, group: 'Required'}, - {name: 'serviceName', label: 'Service name', type: 'text', required: true, group: 'Required'}, - {name: 'taskDefinition', label: 'Task definition', type: 'text', required: true, group: 'Required'}, - {name: 'launchType', label: 'Launch type', type: 'select', required: true, defaultValue: 'FARGATE', group: 'Required', options: [{label: 'Fargate', value: 'FARGATE'}, {label: 'EC2', value: 'EC2'}]}, - {name: 'desiredCount', label: 'Desired tasks', type: 'text', required: false, defaultValue: '1', group: 'Required'}, - {name: 'subnets', label: 'Subnet IDs', type: 'text', required: false, requiredWhen: {field: 'launchType', equals: 'FARGATE'}, group: 'awsvpc networking', span: true, description: 'Required for Fargate. Comma-separated subnets are used for awsvpc task ENIs.'}, - {name: 'securityGroups', label: 'Security group IDs', type: 'text', required: false, group: 'awsvpc networking', span: true, description: 'Optional comma-separated groups for awsvpc tasks.'}, - {name: 'assignPublicIp', label: 'Assign public IP', type: 'select', required: false, defaultValue: 'DISABLED', group: 'awsvpc networking', options: [{label: 'Disabled', value: 'DISABLED'}, {label: 'Enabled', value: 'ENABLED'}]}, - {name: 'targetGroupArn', label: 'ALB target group ARN', type: 'text', required: false, group: 'Load balancing', span: true, description: 'Fargate awsvpc services must use an IP target group. The target health and attached ALB endpoint are shown after creation.'}, - {name: 'containerName', label: 'Container name', type: 'text', required: false, group: 'Load balancing'}, - {name: 'containerPort', label: 'Container port', type: 'text', required: false, group: 'Load balancing'}, + {name: 'resourceKind', label: 'Create', type: 'select', required: true, defaultValue: 'service', group: 'Resource', options: [{label: 'Fargate service', value: 'service'}, {label: 'Cluster', value: 'cluster'}, {label: 'Fargate task definition', value: 'taskDefinition'}]}, + {name: 'clusterName', label: 'New cluster name', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'cluster'}, visibleWhen: {field: 'resourceKind', values: ['cluster']}, group: 'Cluster'}, + {name: 'family', label: 'Task family', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'taskDefinition'}, visibleWhen: {field: 'resourceKind', values: ['taskDefinition']}, group: 'Fargate task definition'}, + {name: 'image', label: 'Container image', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'taskDefinition'}, visibleWhen: {field: 'resourceKind', values: ['taskDefinition']}, group: 'Fargate task definition', span: true}, + {name: 'cpu', label: 'Task CPU units', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'taskDefinition'}, visibleWhen: {field: 'resourceKind', values: ['taskDefinition']}, defaultValue: '256', group: 'Fargate task definition'}, + {name: 'memory', label: 'Task memory (MiB)', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'taskDefinition'}, visibleWhen: {field: 'resourceKind', values: ['taskDefinition']}, defaultValue: '512', group: 'Fargate task definition'}, + {name: 'containerName', label: 'Container name', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'taskDefinition'}, visibleWhen: {field: 'resourceKind', values: ['taskDefinition', 'service']}, group: 'Container and target binding'}, + {name: 'containerPort', label: 'Container port', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'taskDefinition'}, visibleWhen: {field: 'resourceKind', values: ['taskDefinition', 'service']}, group: 'Container and target binding'}, + {name: 'cluster', label: 'Cluster', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'service'}, visibleWhen: {field: 'resourceKind', values: ['service']}, group: 'Fargate service'}, + {name: 'serviceName', label: 'Service name', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'service'}, visibleWhen: {field: 'resourceKind', values: ['service']}, group: 'Fargate service'}, + {name: 'taskDefinition', label: 'Task definition', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'service'}, visibleWhen: {field: 'resourceKind', values: ['service']}, group: 'Fargate service'}, + {name: 'launchType', label: 'Launch type', type: 'select', required: false, visibleWhen: {field: 'resourceKind', values: ['service']}, defaultValue: 'FARGATE', group: 'Fargate service', options: [{label: 'Fargate', value: 'FARGATE'}, {label: 'EC2', value: 'EC2'}]}, + {name: 'deploymentController', label: 'Deployment controller', type: 'select', required: false, visibleWhen: {field: 'resourceKind', values: ['service']}, defaultValue: 'ECS', group: 'Fargate service', options: [{label: 'ECS rolling deployment', value: 'ECS'}, {label: 'CodeDeploy blue/green', value: 'CODE_DEPLOY'}]}, + {name: 'desiredCount', label: 'Desired tasks', type: 'text', required: false, visibleWhen: {field: 'resourceKind', values: ['service']}, defaultValue: '1', group: 'Fargate service'}, + {name: 'subnets', label: 'Subnet IDs', type: 'text', required: false, requiredWhen: {field: 'resourceKind', equals: 'service'}, visibleWhen: {field: 'resourceKind', values: ['service']}, group: 'awsvpc networking', span: true, description: 'Comma-separated subnets used for Fargate task ENIs.'}, + {name: 'securityGroups', label: 'Security group IDs', type: 'text', required: false, visibleWhen: {field: 'resourceKind', values: ['service']}, group: 'awsvpc networking', span: true, description: 'Optional comma-separated groups for awsvpc tasks.'}, + {name: 'assignPublicIp', label: 'Assign public IP', type: 'select', required: false, visibleWhen: {field: 'resourceKind', values: ['service']}, defaultValue: 'DISABLED', group: 'awsvpc networking', options: [{label: 'Disabled', value: 'DISABLED'}, {label: 'Enabled', value: 'ENABLED'}]}, + {name: 'targetGroupArn', label: 'ALB target group ARN', type: 'text', required: false, visibleWhen: {field: 'resourceKind', values: ['service']}, group: 'Container and target binding', span: true, description: 'Optional. Fargate awsvpc services must use an IP target group; container name and port bind it to the task definition.'}, ], actions: ['list', 'create', 'delete', 'inspect'], capabilities: {resourceActions: [ - {name: 'list', label: 'List ECS services', enabled: true, status: 'available', runtimeRequired: true}, - {name: 'inspect', label: 'Inspect service health', enabled: true, status: 'available', runtimeRequired: true}, - {name: 'create', label: 'Create EC2 or Fargate service', enabled: true, status: 'available', runtimeRequired: true}, - {name: 'delete', label: 'Force delete service', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'list', label: 'List ECS resources', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect ECS resource', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create ECS resource', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete ECS resource', enabled: true, status: 'available', runtimeRequired: true}, ]}, filters, columns, } @@ -46,18 +54,12 @@ export function awsFargateSchema(): ServiceSchema { service: 'fargate', displayName: 'AWS Fargate', fields: ecs.fields - .filter((field) => field.name !== 'launchType') - .map((field) => field.name === 'subnets' ? { - ...field, - required: true, - requiredWhen: undefined, - description: 'Required for Fargate awsvpc task ENIs. Comma-separated subnets are used for awsvpc networking.', - } : field), + .filter((field) => !['resourceKind', 'clusterName', 'family', 'image', 'cpu', 'memory', 'launchType'].includes(field.name)) + .map((field) => ['cluster', 'serviceName', 'taskDefinition', 'subnets'].includes(field.name) + ? {...field, required: true, requiredWhen: undefined} + : field), capabilities: { - resourceActions: ecs.capabilities?.resourceActions?.map((action) => action.name === 'create' ? { - ...action, - label: 'Create Fargate service', - } : action), + resourceActions: ecs.capabilities?.resourceActions?.map((action) => action.name === 'create' ? {...action, label: 'Create Fargate service'} : action), }, } } diff --git a/packages/api/src/cloud-spi/kinesisSchema.ts b/packages/api/src/cloud-spi/kinesisSchema.ts new file mode 100644 index 0000000..1dbd0db --- /dev/null +++ b/packages/api/src/cloud-spi/kinesisSchema.ts @@ -0,0 +1,51 @@ +import type {CapabilitySchema, FieldSchema, ResourceActionName, ServiceSchema} from './types' + +const streamActions: CapabilitySchema[] = [ + {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}, + } +} diff --git a/packages/api/src/cloud-spi/networkingSchema.ts b/packages/api/src/cloud-spi/networkingSchema.ts index 82a0a03..973c122 100644 --- a/packages/api/src/cloud-spi/networkingSchema.ts +++ b/packages/api/src/cloud-spi/networkingSchema.ts @@ -44,6 +44,11 @@ export function awsNetworkingSchema(): ServiceSchema { runtimeRequired: true, }, ], + itemActions: [ + {name: 'list', label: 'Inspect ingress rules', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'put', label: 'Add ingress rule', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete ingress rule', enabled: true, status: 'available', runtimeRequired: true}, + ], }, } } diff --git a/packages/api/src/cloud-spi/serviceCatalog.ts b/packages/api/src/cloud-spi/serviceCatalog.ts index a69daa6..90c9f14 100644 --- a/packages/api/src/cloud-spi/serviceCatalog.ts +++ b/packages/api/src/cloud-spi/serviceCatalog.ts @@ -21,6 +21,7 @@ export type ServiceGroup = | 'Databases' | 'Networking' | 'Integration' + | 'Analytics' | 'Provisioning' | 'Security' | 'Observability' @@ -32,6 +33,7 @@ export const SERVICE_GROUP_ORDER: ServiceGroup[] = [ 'Databases', 'Networking', 'Integration', + 'Analytics', 'Provisioning', 'Security', 'Observability', @@ -171,6 +173,29 @@ export const SERVICE_CATALOG = { group: 'Integration', order: 15, }, + msk: { + displayName: 'Managed Streaming for Kafka', + displayNameByCloud: {aws: 'MSK'}, + iconKey: 'msk', + group: 'Analytics', + order: 16, + legacyAvailability: {aws: 'available'}, + }, + 'msk-connect': { + displayName: 'Managed Streaming for Kafka Connect', + displayNameByCloud: {aws: 'MSK Connect'}, + iconKey: 'msk-connect', + group: 'Analytics', + order: 17, + legacyAvailability: {aws: 'available'}, + }, + kinesis: { + displayName: 'Kinesis Data Streams', + displayNameByCloud: {aws: 'Kinesis'}, + iconKey: 'kinesis', + group: 'Analytics', + order: 18, + }, identity: {displayName: 'Identity', iconKey: 'iam', group: 'Security', order: 5}, apigateway: {displayName: 'API Gateway', iconKey: 'apigateway', group: 'Integration', order: 10}, email: { diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index cf4a250..4e0dcf2 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -85,6 +85,7 @@ export interface FieldSchema { type: FieldType required: boolean requiredWhen?: {field: string; equals: string} + visibleWhen?: {field: string; values: string[]} description?: string group?: string span?: boolean @@ -697,6 +698,37 @@ export interface SqsLambdaEventSourceMappingOverview { stateTransitionReason: string | null } +export interface KinesisPutRecordInput { + partitionKey: string + data: string +} + +export interface KinesisRecord { + sequenceNumber: string + partitionKey: string | null + data: string + approximateArrivalTimestamp: string | null +} + +export interface LambdaEventSourceMappingInput { + functionName: string + eventSourceArn: string + batchSize?: number + startingPosition?: 'TRIM_HORIZON' | 'LATEST' +} + +export interface LambdaEventSourceMappingOverview { + id: string + uuid: string + functionName: string | null + functionArn: string | null + eventSourceArn: string + batchSize: number | null + startingPosition: string | null + state: string | null + stateTransitionReason: string | null +} + export type KmsEncryptionAlgorithm = | 'SYMMETRIC_DEFAULT' | 'RSAES_OAEP_SHA_1' @@ -791,6 +823,12 @@ export interface CloudServiceAdapter { createSqsLambdaEventSourceMapping?(input: SqsLambdaEventSourceMappingInput): Promise listSqsLambdaEventSourceMappings?(functionName: string): Promise deleteSqsLambdaEventSourceMapping?(functionName: string, mappingId: string): Promise + putKinesisRecord?(streamName: string, input: KinesisPutRecordInput): Promise<{sequenceNumber: string; shardId: string}> + listKinesisRecords?(streamName: string): Promise + createLambdaEventSourceMapping?(input: LambdaEventSourceMappingInput): Promise + listLambdaEventSourceMappings?(filter?: {functionName?: string; eventSourceArn?: string}): Promise + updateLambdaEventSourceMapping?(mappingId: string, input: {batchSize?: number; enabled?: boolean}): Promise + deleteLambdaEventSourceMapping?(mappingId: string): Promise encrypt?(id: string, input: KmsEncryptInput): Promise decrypt?(id: string, input: KmsDecryptInput): Promise // Lifecycle verbs. Optional because most categories have no notion of them; diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index f09db33..bd838af 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -44,6 +44,7 @@ import {AwsCodePipelineAdapter} from './adapter-aws/AwsCodePipelineAdapter' import {AwsCodeDeployAdapter} from './adapter-aws/AwsCodeDeployAdapter' import {AwsEcsAdapter} from './adapter-aws/AwsEcsAdapter' import {AwsFargateAdapter} from './adapter-aws/AwsFargateAdapter' +import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter' import {awsClientsForAccount, resolveAccountId} from './aws' import {createEc2Service} from './services/ec2' import {createEksService} from './services/eks' @@ -90,6 +91,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap new AwsCodeDeployAdapter(clients.codedeploy, clients.lambda), new AwsEcsAdapter(clients.ecs, clients.elbv2), new AwsFargateAdapter(new AwsEcsAdapter(clients.ecs, clients.elbv2)), + new AwsKinesisAdapter(clients.kinesis), new AzureStorageAdapter(), new AzureServiceBusAdapter(), new AzureDatabaseAdapter(), diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index d9200e3..d7b3ccd 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -7,6 +7,9 @@ import eks from "./routes/eks"; import ec2 from "./routes/ec2"; import secretsmanager from "./routes/secretsmanager"; import clouds from "./routes/clouds"; +import msk from "./routes/msk"; +import mskConnect from "./routes/mskConnect"; +import ses from "./routes/ses"; const app = new Hono(); // Secrets Manager and KMS crypto routes handle sensitive values with server-side @@ -43,6 +46,9 @@ app.route("/api/eks", eks); app.route("/api/ec2", ec2); app.route("/api/secretsmanager", secretsmanager); app.route("/api/clouds", clouds); +app.route("/api/msk", msk); +app.route("/api/msk-connect", mskConnect); +app.route("/api/ses", ses); // Serve static frontend files when public/ directory is present (production) app.use("*", serveStatic({ root: "./public" })); diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index 02973ce..6cf0c79 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -963,6 +963,64 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) + app.post('/:cloud/services/kinesis/resources/:id/records', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => { + const input = await jsonBody<{partitionKey?: unknown; data?: unknown}>(c) + return c.json(await svc(c).putKinesisRecord(cloud, c.req.param('id'), { + partitionKey: typeof input.partitionKey === 'string' ? input.partitionKey : '', + data: typeof input.data === 'string' ? input.data : '', + }), 201) + }) + }) + + app.get('/:cloud/services/kinesis/resources/:id/records', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => c.json(await svc(c).listKinesisRecords(cloud, c.req.param('id')))) + }) + + app.get('/:cloud/services/serverless/event-source-mappings', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => c.json(await svc(c).listLambdaEventSourceMappings(cloud, { + functionName: c.req.query('functionName'), eventSourceArn: c.req.query('eventSourceArn'), + }))) + }) + + app.post('/:cloud/services/serverless/event-source-mappings', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => { + const input = await jsonBody<{functionName?: unknown; eventSourceArn?: unknown; batchSize?: unknown; startingPosition?: unknown}>(c) + return c.json(await svc(c).createLambdaEventSourceMapping(cloud, { + functionName: typeof input.functionName === 'string' ? input.functionName : '', + eventSourceArn: typeof input.eventSourceArn === 'string' ? input.eventSourceArn : '', + batchSize: typeof input.batchSize === 'number' ? input.batchSize : undefined, + startingPosition: input.startingPosition === 'TRIM_HORIZON' || input.startingPosition === 'LATEST' ? input.startingPosition : undefined, + }), 201) + }) + }) + + app.patch('/:cloud/services/serverless/event-source-mappings/:mappingId', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => { + const input = await jsonBody<{batchSize?: unknown; enabled?: unknown}>(c) + return c.json(await svc(c).updateLambdaEventSourceMapping(cloud, c.req.param('mappingId'), { + batchSize: typeof input.batchSize === 'number' ? input.batchSize : undefined, + enabled: typeof input.enabled === 'boolean' ? input.enabled : undefined, + })) + }) + }) + + app.delete('/:cloud/services/serverless/event-source-mappings/:mappingId', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => { await svc(c).deleteLambdaEventSourceMapping(cloud, c.req.param('mappingId')); return c.json({ok: true}) }) + }) + app.get('/:cloud/services/:service/resources/:id/collections', async (c) => { const target = childTarget(c) if (!target) return c.json({error: 'Unknown cloud or service'}, 404) diff --git a/packages/api/src/routes/ec2.test.ts b/packages/api/src/routes/ec2.test.ts index 883d3a6..a321e8a 100644 --- a/packages/api/src/routes/ec2.test.ts +++ b/packages/api/src/routes/ec2.test.ts @@ -75,6 +75,7 @@ const fakeService = { createImage: async (_input: CreateAmiInput): Promise<{imageId: string}> => ({imageId: 'ami-0a1b2c3d4e5f6a7b'}), deregisterImage: async () => {}, getConsoleOutput: async (instanceId: string): Promise => ({instanceId, output: 'boot log'}), + updateInstanceSecurityGroups: async () => {}, listAmis: async (): Promise => [], listKeyPairs: async (): Promise => [], createKeyPair: async (name: string, _keyType: Ec2KeyPairType = 'rsa'): Promise => ({keyPairId: 'key-0a1b2c3d4e5f6a7b', keyName: name, keyMaterial: '---PEM---'}), @@ -201,6 +202,22 @@ describe('POST /instances/:id/terminate', () => { }) }) +describe('PUT /instances/:id/security-groups', () => { + test('replaces security groups', async () => { + let receivedGroupIds: string[] | undefined + const router = createEc2Router({...fakeService, updateInstanceSecurityGroups: async (_instanceId, groupIds) => { + receivedGroupIds = groupIds + }}) + const res = await router.request('/instances/i-test1/security-groups', { + method: 'PUT', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({groupIds: ['sg-123']}), + }) + expect(res.status).toBe(200) + expect(receivedGroupIds).toEqual(['sg-123']) + }) +}) + describe('POST /amis/:imageId/deregister', () => { test('deregisters AMI and returns ok', async () => { const res = await app.request('/amis/ami-0a1b2c3d4e5f6a7b/deregister', {method: 'POST'}) diff --git a/packages/api/src/routes/ec2.ts b/packages/api/src/routes/ec2.ts index d381792..e1e19a3 100644 --- a/packages/api/src/routes/ec2.ts +++ b/packages/api/src/routes/ec2.ts @@ -15,6 +15,31 @@ import {ec2Service} from '../services/ec2' type Ec2Service = typeof ec2Service type CreateKeyPairInput = {name: string; keyType?: Ec2KeyPairType} +type Ec2DiagnosticResponse = {ec2?: {instances?: Array<{ + instanceId?: string + sshHost?: string + sshPort?: number + containerId?: string + containerStatus?: 'running' | 'stopped' | 'unavailable' +}>}} + +async function connectionDetails(instanceId: string) { + const endpoint = process.env.FLOCI_ENDPOINT + if (!endpoint) { + return undefined + } + try { + const response = await fetch(`${endpoint}/_floci/diagnose`) + if (!response.ok) { + return undefined + } + return (await response.json() as Ec2DiagnosticResponse).ec2?.instances + ?.find((instance) => instance.instanceId === instanceId) + } catch { + return undefined + } +} + function isEc2KeyPairType(value: unknown): value is Ec2KeyPairType { return value === 'rsa' || value === 'ed25519' } @@ -33,7 +58,9 @@ export function createEc2Router(svc: Ec2Service = ec2Service) { }) app.get('/instances/:instanceId', async (c) => { - return c.json(await svc.describeInstance(c.req.param('instanceId'))) + const instanceId = c.req.param('instanceId') + const [instance, connection] = await Promise.all([svc.describeInstance(instanceId), connectionDetails(instanceId)]) + return c.json({...instance, connection}) }) app.post('/instances', async (c) => { @@ -91,6 +118,19 @@ export function createEc2Router(svc: Ec2Service = ec2Service) { } }) + app.put('/instances/:instanceId/security-groups', async (c) => { + try { + const {groupIds} = await c.req.json<{groupIds: string[]}>() + if (!Array.isArray(groupIds) || groupIds.length === 0 || groupIds.some((groupId) => !groupId)) { + return c.json({error: 'At least one security group is required'}, 400) + } + await svc.updateInstanceSecurityGroups(c.req.param('instanceId'), groupIds) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + app.post('/instances/:instanceId/image', async (c) => { try { const body = await c.req.json>() diff --git a/packages/api/src/routes/msk.test.ts b/packages/api/src/routes/msk.test.ts new file mode 100644 index 0000000..690cb0c --- /dev/null +++ b/packages/api/src/routes/msk.test.ts @@ -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: [] }]); + }); +}); diff --git a/packages/api/src/routes/msk.ts b/packages/api/src/routes/msk.ts new file mode 100644 index 0000000..f3059e3 --- /dev/null +++ b/packages/api/src/routes/msk.ts @@ -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()), 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()), 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()), 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(); diff --git a/packages/api/src/routes/mskConnect.test.ts b/packages/api/src/routes/mskConnect.test.ts new file mode 100644 index 0000000..d75860e --- /dev/null +++ b/packages/api/src/routes/mskConnect.test.ts @@ -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); + }); +}); diff --git a/packages/api/src/routes/mskConnect.ts b/packages/api/src/routes/mskConnect.ts new file mode 100644 index 0000000..7981180 --- /dev/null +++ b/packages/api/src/routes/mskConnect.ts @@ -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(); diff --git a/packages/api/src/routes/ses.ts b/packages/api/src/routes/ses.ts new file mode 100644 index 0000000..bd722f9 --- /dev/null +++ b/packages/api/src/routes/ses.ts @@ -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()), 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()), 201)); + app.post("/emails/raw", async (c) => c.json(await service.sendRaw(await c.req.json()), 201)); + app.post("/emails/template", async (c) => c.json(await service.sendTemplate(await c.req.json()), 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()), 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()), 201)); + app.put("/templates/:templateName", async (c) => c.json(await service.updateTemplate(c.req.param("templateName"), await c.req.json>()))); + 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()))); + + 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()), 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>()), 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(); diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index d6c84e5..a20ba5a 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -43,6 +43,10 @@ import type { CloudFormationChangeSetInput, SqsLambdaEventSourceMappingInput, SqsLambdaEventSourceMappingOverview, + KinesisPutRecordInput, + KinesisRecord, + LambdaEventSourceMappingInput, + LambdaEventSourceMappingOverview, SqsMessage, SqsMoveTaskInput, SqsMoveTaskResult, @@ -439,6 +443,42 @@ export class CloudProxyService { await adapter.deleteSqsLambdaEventSourceMapping(functionName, mappingId) } + async putKinesisRecord(cloud: CloudProvider, streamName: string, input: KinesisPutRecordInput): Promise<{sequenceNumber: string; shardId: string}> { + const adapter = this.requireAdapter(cloud, 'kinesis') + if (!adapter.putKinesisRecord) throw new NotSupportedError(`Kinesis record production is not supported for ${cloud}`) + return adapter.putKinesisRecord(streamName, input) + } + + async listKinesisRecords(cloud: CloudProvider, streamName: string): Promise { + 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 { + 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 { + 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 { + 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 { + const adapter = this.requireAdapter(cloud, 'serverless') + if (!adapter.deleteLambdaEventSourceMapping) throw new NotSupportedError(`Lambda event source mappings are not supported for ${cloud}`) + await adapter.deleteLambdaEventSourceMapping(mappingId) + } + async sendSqsMessage(cloud: CloudProvider, queueId: string, input: SqsSendMessageInput): Promise<{messageId: string}> { const adapter = this.requireAdapter(cloud, 'messaging') if (!adapter.sendSqsMessage) throw new NotSupportedError(`SQS message sending is not supported for ${cloud}`) diff --git a/packages/api/src/services/ec2.ts b/packages/api/src/services/ec2.ts index 9d13279..a5f84a1 100644 --- a/packages/api/src/services/ec2.ts +++ b/packages/api/src/services/ec2.ts @@ -43,6 +43,7 @@ import { DisassociateRouteTableCommand, GetConsoleOutputCommand, ModifySubnetAttributeCommand, + ModifyInstanceAttributeCommand, ModifyVpcAttributeCommand, RebootInstancesCommand, ReleaseAddressCommand, @@ -140,6 +141,14 @@ export type Ec2Instance = { securityGroups: Ec2SecurityGroupRef[] networkInterfaces: Ec2NetworkInterface[] tags: Ec2Tag[] + connection?: Ec2InstanceConnection +} + +export type Ec2InstanceConnection = { + sshHost?: string + sshPort?: number + containerId?: string + containerStatus: 'running' | 'stopped' | 'unavailable' } export type Ec2KeyPair = { @@ -572,6 +581,13 @@ export function createEc2Service(client: EC2Client = awsClients.ec2) { } }, + async updateInstanceSecurityGroups(instanceId: string, groupIds: string[]): Promise { + await client.send(new ModifyInstanceAttributeCommand({ + InstanceId: instanceId, + Groups: groupIds, + })) + }, + async createImage(input: CreateAmiInput): Promise<{imageId: string}> { const res = await client.send(new CreateImageCommand({ InstanceId: input.instanceId, diff --git a/packages/api/src/services/msk.test.ts b/packages/api/src/services/msk.test.ts new file mode 100644 index 0000000..4064ff2 --- /dev/null +++ b/packages/api/src/services/msk.test.ts @@ -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"); + }); +}); diff --git a/packages/api/src/services/msk.ts b/packages/api/src/services/msk.ts new file mode 100644 index 0000000..608c7f3 --- /dev/null +++ b/packages/api/src/services/msk.ts @@ -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; +}; + +export type CreateTopicInput = { + topic: string; + numPartitions?: number; + replicationFactor?: number; + configEntries?: { name: string; value: string }[]; +}; + +export type ProduceMessageInput = { + key?: string; + value: string; + headers?: Record; +}; + +type KafkaAdmin = { + connect(): Promise; + disconnect(): Promise; + fetchTopicMetadata(input?: { topics?: string[] }): Promise<{ + topics: { name: string; partitions: unknown[] }[]; + }>; + createTopics(input: { topics: CreateTopicInput[] }): Promise; + deleteTopics(input: { topics: string[] }): Promise; + 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; + disconnect(): Promise; + send(input: { + topic: string; + messages: { key?: string; value: string; headers?: Record }[]; + }): Promise<{ topicName: string; partition: number; baseOffset?: string }[]>; +}; + +type KafkaConsumer = { + connect(): Promise; + disconnect(): Promise; + subscribe(input: { topic: string; fromBeginning: boolean }): Promise; + run(input: { + autoCommit: boolean; + eachMessage: (payload: { + partition: number; + message: { + key: Buffer | null; + value: Buffer | null; + headers?: Record; + offset: string; + timestamp: string; + }; + }) => Promise; + }): Promise; + stop(): Promise; +}; + +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; +} + +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 { + const command = new GetBootstrapBrokersCommand({ ClusterArn: clusterArn }); + command.middlewareStack.add( + (next) => async (args) => { + const request = args.request as { headers: Record }; + 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 }[] = []; + await consumer.connect(); + try { + await consumer.subscribe({ topic, fromBeginning: true }); + await new Promise(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(); diff --git a/packages/api/src/services/mskConnect.test.ts b/packages/api/src/services/mskConnect.test.ts new file mode 100644 index 0000000..4652ec9 --- /dev/null +++ b/packages/api/src/services/mskConnect.test.ts @@ -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); +}); diff --git a/packages/api/src/services/mskConnect.ts b/packages/api/src/services/mskConnect.ts new file mode 100644 index 0000000..2151a55 --- /dev/null +++ b/packages/api/src/services/mskConnect.ts @@ -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(); diff --git a/packages/api/src/services/ses.test.ts b/packages/api/src/services/ses.test.ts new file mode 100644 index 0000000..f22bf61 --- /dev/null +++ b/packages/api/src/services/ses.test.ts @@ -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; + } + }); +}); diff --git a/packages/api/src/services/ses.ts b/packages/api/src/services/ses.ts new file mode 100644 index 0000000..38ce282 --- /dev/null +++ b/packages/api/src/services/ses.ts @@ -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 & { + 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) { + 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) { + 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(path: string, nextToken?: string): Promise { + 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; +} diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index b5bc4bb..b75bd05 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -58,7 +58,13 @@ export const apiEndpointKeys = { updateCloudFormationTerminationProtection: "clouds.services.resources.iac.termination-protection.update", createSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.create", listSqsLambdaEventSourceMappings: "clouds.services.resources.serverless.sqs-event-source-mappings.list", - deleteSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.delete", + deleteSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.delete", + putKinesisRecord: "clouds.services.resources.kinesis.record.put", + listKinesisRecords: "clouds.services.resources.kinesis.records.list", + createLambdaEventSourceMapping: "clouds.services.resources.serverless.event-source-mapping.create", + listLambdaEventSourceMappings: "clouds.services.resources.serverless.event-source-mappings.list", + updateLambdaEventSourceMapping: "clouds.services.resources.serverless.event-source-mapping.update", + deleteLambdaEventSourceMapping: "clouds.services.resources.serverless.event-source-mapping.delete", startWorkflowExecution: "clouds.services.resources.workflows.execution.start", listWorkflowExecutions: "clouds.services.resources.workflows.executions.list", describeWorkflowExecution: "clouds.services.resources.workflows.execution.describe", @@ -219,6 +225,7 @@ export const apiEndpointKeys = { reboot: "aws.ec2.instances.reboot", terminate: "aws.ec2.instances.terminate", tags: "aws.ec2.instances.tags", + securityGroups: "aws.ec2.instances.securityGroups", createImage: "aws.ec2.instances.create-image", console: "aws.ec2.instances.console", }, @@ -547,6 +554,12 @@ export const endpointRegistry: EndpointRegistry = new Map([ method: "DELETE", }, ], + [apiEndpointKeys.clouds.resources.putKinesisRecord, {path: "/clouds/:cloud/services/kinesis/resources/:id/records", method: "POST"}], + [apiEndpointKeys.clouds.resources.listKinesisRecords, {path: "/clouds/:cloud/services/kinesis/resources/:id/records", method: "GET"}], + [apiEndpointKeys.clouds.resources.createLambdaEventSourceMapping, {path: "/clouds/:cloud/services/serverless/event-source-mappings", method: "POST"}], + [apiEndpointKeys.clouds.resources.listLambdaEventSourceMappings, {path: "/clouds/:cloud/services/serverless/event-source-mappings", method: "GET"}], + [apiEndpointKeys.clouds.resources.updateLambdaEventSourceMapping, {path: "/clouds/:cloud/services/serverless/event-source-mappings/:mappingId", method: "PATCH"}], + [apiEndpointKeys.clouds.resources.deleteLambdaEventSourceMapping, {path: "/clouds/:cloud/services/serverless/event-source-mappings/:mappingId", method: "DELETE"}], [ apiEndpointKeys.clouds.resources.startWorkflowExecution, { path: "/clouds/:cloud/services/workflows/resources/:id/executions", method: "POST" }, @@ -1113,6 +1126,7 @@ export const endpointRegistry: EndpointRegistry = new Map([ [apiEndpointKeys.aws.ec2.instances.reboot, { path: "/ec2/instances/:instanceId/reboot", method: "POST" }], [apiEndpointKeys.aws.ec2.instances.terminate,{ path: "/ec2/instances/:instanceId/terminate", method: "POST" }], [apiEndpointKeys.aws.ec2.instances.tags, { path: "/ec2/instances/:instanceId/tags", method: "PUT" }], + [apiEndpointKeys.aws.ec2.instances.securityGroups, { path: "/ec2/instances/:instanceId/security-groups", method: "PUT" }], [apiEndpointKeys.aws.ec2.instances.createImage, { path: "/ec2/instances/:instanceId/image", method: "POST" }], [apiEndpointKeys.aws.ec2.instances.console, { path: "/ec2/instances/:instanceId/console", method: "GET" }], [apiEndpointKeys.aws.ec2.amis.list, { path: "/ec2/amis", method: "GET" }], diff --git a/packages/frontend/src/api/aws/ec2.api.ts b/packages/frontend/src/api/aws/ec2.api.ts index f14519f..e3fe169 100644 --- a/packages/frontend/src/api/aws/ec2.api.ts +++ b/packages/frontend/src/api/aws/ec2.api.ts @@ -75,6 +75,14 @@ export type Ec2Instance = { securityGroups: Ec2SecurityGroupRef[]; networkInterfaces: Ec2NetworkInterface[]; tags: Ec2Tag[]; + connection?: Ec2InstanceConnection; +}; + +export type Ec2InstanceConnection = { + sshHost?: string; + sshPort?: number; + containerId?: string; + containerStatus: 'running' | 'stopped' | 'unavailable'; }; export type Ec2KeyPair = { @@ -327,6 +335,14 @@ export async function updateEc2InstanceTags( ); } +export async function updateEc2InstanceSecurityGroups(instanceId: string, groupIds: string[]): Promise { + await apiClient.call( + apiEndpointKeys.aws.ec2.instances.securityGroups, + { body: JSON.stringify({ groupIds }), headers: { "content-type": "application/json" } }, + { instanceId }, + ); +} + export async function createEc2Ami(instanceId: string, input: CreateAmiInput): Promise<{ imageId: string }> { const res = await apiClient.call<{ imageId: string }>( apiEndpointKeys.aws.ec2.instances.createImage, diff --git a/packages/frontend/src/api/cloudProxyClient.flatItems.test.ts b/packages/frontend/src/api/cloudProxyClient.flatItems.test.ts new file mode 100644 index 0000000..bed5ba8 --- /dev/null +++ b/packages/frontend/src/api/cloudProxyClient.flatItems.test.ts @@ -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() + + 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') + }) +}) diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 93af312..bcc8bae 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -521,6 +521,39 @@ export interface SqsLambdaEventSourceMappingOverview { stateTransitionReason: string | null; } +export interface KinesisRecord {sequenceNumber: string; partitionKey: string | null; data: string; approximateArrivalTimestamp: string | null} +export interface LambdaEventSourceMappingOverview {id: string; uuid: string; functionName: string | null; functionArn: string | null; eventSourceArn: string; batchSize: number | null; startingPosition: string | null; state: string | null; stateTransitionReason: string | null} +export interface CreateLambdaEventSourceMappingInput {functionName: string; eventSourceArn: string; batchSize?: number; startingPosition?: "TRIM_HORIZON" | "LATEST"} + +export async function putKinesisRecord(cloud: CloudProvider, streamName: string, input: {partitionKey: string; data: string}): Promise<{sequenceNumber: string; shardId: string}> { + const res = await apiClient.call<{sequenceNumber: string; shardId: string}, typeof input>(apiEndpointKeys.clouds.resources.putKinesisRecord, requestOptions(cloud, "kinesis", {body: input}), {cloud, id: streamName}) + return res.data +} + +export async function listKinesisRecords(cloud: CloudProvider, streamName: string, signal?: AbortSignal): Promise { + const res = await apiClient.call(apiEndpointKeys.clouds.resources.listKinesisRecords, requestOptions(cloud, "kinesis", {signal}), {cloud, id: streamName}) + return res.data +} + +export async function createLambdaEventSourceMapping(cloud: CloudProvider, input: CreateLambdaEventSourceMappingInput): Promise { + const res = await apiClient.call(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 { + const res = await apiClient.call(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 { + const res = await apiClient.call(apiEndpointKeys.clouds.resources.updateLambdaEventSourceMapping, requestOptions(cloud, "serverless", {body: input}), {cloud, mappingId}) + return res.data +} + +export async function deleteLambdaEventSourceMapping(cloud: CloudProvider, mappingId: string): Promise { + await apiClient.call(apiEndpointKeys.clouds.resources.deleteLambdaEventSourceMapping, requestOptions(cloud, "serverless"), {cloud, mappingId}) +} + export async function createApiGatewayLambdaRoute( cloud: CloudProvider, apiId: string, diff --git a/packages/frontend/src/api/mskClient.ts b/packages/frontend/src/api/mskClient.ts new file mode 100644 index 0000000..dbca4f5 --- /dev/null +++ b/packages/frontend/src/api/mskClient.ts @@ -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; } +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 { return (await apiClient.get("/msk/clusters", {signal})).data; } +export async function createMskCluster(input: CreateMskClusterInput): Promise { return (await apiClient.post("/msk/clusters", input)).data; } +export async function deleteMskCluster(clusterArn: string): Promise { await apiClient.delete(clusterPath(clusterArn)); } +export async function getMskBootstrapBrokers(clusterArn: string): Promise { return (await apiClient.get<{bootstrapBrokers: string}>(`${clusterPath(clusterArn)}/bootstrap-brokers`)).data.bootstrapBrokers; } +export async function listMskTopics(clusterArn: string, signal?: AbortSignal): Promise { return (await apiClient.get(`${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 { await apiClient.post(`${clusterPath(clusterArn)}/topics`, input); } +export async function deleteMskTopic(clusterArn: string, topic: string): Promise { await apiClient.delete(`${clusterPath(clusterArn)}/topics/${encodeURIComponent(topic)}`); } +export async function produceMskMessage(clusterArn: string, topic: string, input: {key?: string; value: string; headers?: Record}): Promise> { return (await apiClient.post, typeof input>(`${clusterPath(clusterArn)}/topics/${encodeURIComponent(topic)}/messages`, input)).data; } +export async function peekMskMessages(clusterArn: string, topic: string, limit: number): Promise { return (await apiClient.get(`${clusterPath(clusterArn)}/topics/${encodeURIComponent(topic)}/messages`, {params: {limit}})).data; } +export async function getMskConsumerGroupLag(clusterArn: string, groupId: string, topic?: string): Promise { return (await apiClient.get(`${clusterPath(clusterArn)}/consumer-groups/${encodeURIComponent(groupId)}/lag`, {params: {topic}})).data; } diff --git a/packages/frontend/src/api/mskConnectClient.ts b/packages/frontend/src/api/mskConnectClient.ts new file mode 100644 index 0000000..bcefa41 --- /dev/null +++ b/packages/frontend/src/api/mskConnectClient.ts @@ -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; + 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 { + currentVersion: string; +} + +const basePath = "/msk-connect"; + +export async function listCustomPlugins(signal?: AbortSignal): Promise { + return (await apiClient.get(`${basePath}/custom-plugins`, {signal})).data; +} + +export async function registerCustomPlugin(input: {name: string; bucket: string; fileKey: string; objectVersion?: string; description?: string}): Promise { + return (await apiClient.post(`${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 { + await apiClient.delete(`${basePath}/custom-plugins/${encodeURIComponent(customPluginArn)}`); +} + +export async function listConnectors(signal?: AbortSignal): Promise { + return (await apiClient.get(`${basePath}/connectors`, {signal})).data; +} + +export async function describeConnector(connectorArn: string, signal?: AbortSignal): Promise { + return (await apiClient.get(`${basePath}/connectors/${encodeURIComponent(connectorArn)}`, {signal})).data; +} + +export async function createS3SinkConnector(input: S3SinkConnectorInput): Promise { + return (await apiClient.post(`${basePath}/connectors`, connectorRequest(input), {timeout: 60_000})).data; +} + +export async function updateS3SinkConnector(connectorArn: string, input: UpdateS3SinkConnectorInput): Promise { + return (await apiClient.patch(`${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 { + 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): Record { + 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"; +} diff --git a/packages/frontend/src/api/sesClient.ts b/packages/frontend/src/api/sesClient.ts new file mode 100644 index 0000000..1a4bf7b --- /dev/null +++ b/packages/frontend/src/api/sesClient.ts @@ -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 { return (await apiClient.get('/ses/identities')).data } +export async function createSesIdentity(emailIdentity: string): Promise { await apiClient.post('/ses/identities', {EmailIdentity: emailIdentity}) } +export async function deleteSesIdentity(identity: string): Promise { await apiClient.delete(`/ses/identities/${encodeURIComponent(identity)}`) } +export async function sendSesSimple(input: unknown): Promise { await apiClient.post('/ses/emails/simple', input) } +export async function sendSesRaw(input: unknown): Promise { await apiClient.post('/ses/emails/raw', input) } +export async function sendSesTemplate(input: unknown): Promise { await apiClient.post('/ses/emails/template', input) } +export async function listSesSuppressions(): Promise> { return (await apiClient.get>('/ses/suppressions')).data } +export async function putSesSuppression(emailAddress: string, reason: 'BOUNCE' | 'COMPLAINT'): Promise { await apiClient.post('/ses/suppressions', {EmailAddress: emailAddress, Reason: reason}) } +export async function deleteSesSuppression(emailAddress: string): Promise { await apiClient.delete(`/ses/suppressions/${encodeURIComponent(emailAddress)}`) } +export async function listSesTemplates(): Promise { return (await apiClient.get('/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 { 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 { await apiClient.delete(`/ses/templates/${encodeURIComponent(templateName)}`) } +export async function renderSesTemplate(templateName: string, templateData: string): Promise { 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 { return (await apiClient.get('/ses/configuration-sets')).data } +export async function createSesConfigurationSet(configurationSetName: string): Promise { await apiClient.post('/ses/configuration-sets', {ConfigurationSetName: configurationSetName}) } +export async function deleteSesConfigurationSet(name: string): Promise { await apiClient.delete(`/ses/configuration-sets/${encodeURIComponent(name)}`) } +export async function listSesEventDestinations(name: string): Promise { return (await apiClient.get(`/ses/configuration-sets/${encodeURIComponent(name)}/event-destinations`)).data } +export async function createSesEventDestination(name: string, input: unknown): Promise { await apiClient.post(`/ses/configuration-sets/${encodeURIComponent(name)}/event-destinations`, input) } +export async function deleteSesEventDestination(name: string, destination: string): Promise { await apiClient.delete(`/ses/configuration-sets/${encodeURIComponent(name)}/event-destinations/${encodeURIComponent(destination)}`) } +export async function listSesTopics(): Promise { return (await apiClient.get('/ses/inventory/sns-topics')).data } +export async function listSesEventBuses(): Promise { return (await apiClient.get('/ses/inventory/event-buses')).data } diff --git a/packages/frontend/src/components/ApiGatewayRoutePanel.tsx b/packages/frontend/src/components/ApiGatewayRoutePanel.tsx index ca297bd..a4b21ed 100644 --- a/packages/frontend/src/components/ApiGatewayRoutePanel.tsx +++ b/packages/frontend/src/components/ApiGatewayRoutePanel.tsx @@ -141,7 +141,7 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime

Select a REST API

Select an API Gateway REST API to connect a route to a Lambda function.

- + ) } @@ -158,6 +158,7 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime const baseInvokeUrl = endpoint ? `${endpoint}/execute-api/${resource.id}/${stage}` : null return ( +
@@ -165,14 +166,6 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime

Connect Lambda

Creates and deploys one Lambda proxy route to the selected REST API.

- {(testMutation.data || methodTestMutation.data) && functionName && ( - - )} {functionName && Open Lambda logs}
@@ -298,9 +291,10 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime })}
)} - {corsProxyMethods.length > 0 && } -
-
+ {corsProxyMethods.length > 0 && } +
+ +

Method Test

@@ -442,8 +436,14 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime ) :

Send a request to inspect the API response.

}
- + {(testMutation.data || methodTestMutation.data) && functionName && }
+ ) } diff --git a/packages/frontend/src/components/CodeBuildPanel.test.tsx b/packages/frontend/src/components/CodeBuildPanel.test.tsx new file mode 100644 index 0000000..e77bbbc --- /dev/null +++ b/packages/frontend/src/components/CodeBuildPanel.test.tsx @@ -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() + + 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']) +}) diff --git a/packages/frontend/src/components/CodeBuildPanel.tsx b/packages/frontend/src/components/CodeBuildPanel.tsx index d549541..60a1bda 100644 --- a/packages/frontend/src/components/CodeBuildPanel.tsx +++ b/packages/frontend/src/components/CodeBuildPanel.tsx @@ -18,7 +18,7 @@ export function CodeBuildPanel({resource, runtimeReachable}: CodeBuildPanelProps const buildsKey = ['codebuild-builds', resource?.id] const builds = useQuery({queryKey: buildsKey, queryFn: ({signal}) => listCodeBuilds(resource!.id, signal), enabled, refetchInterval: (query) => query.state.data?.some((build) => active(build.buildStatus)) ? 2_000 : false}) const detail = useQuery({queryKey: ['codebuild-build', resource?.id, selectedId], queryFn: ({signal}) => getCodeBuild(resource!.id, selectedId!, signal), enabled: enabled && Boolean(selectedId), refetchInterval: (query) => active(query.state.data?.buildStatus) ? 2_000 : false}) - const start = useMutation({mutationFn: () => startCodeBuild(resource!.id, {environmentVariablesOverride: overrides.length ? overrides : undefined, buildspecOverride: buildspecOverride.trim() || undefined}), onSuccess: (build) => { setSelectedId(build.id); setStartOpen(false); setOverrides([]); setBuildspecOverride(DEFAULT_BUILDSPEC); void client.invalidateQueries({queryKey: buildsKey}) }}) + const start = useMutation({mutationFn: () => startCodeBuild(resource!.id, {environmentVariablesOverride: overrides.length ? overrides : undefined, buildspecOverride: buildspecOverride.trim() || undefined}), onSuccess: (build) => { setSelectedId(build.id); setStartOpen(false); setOverrides([]); setBuildspecOverride(defaultBuildspec(projectSourceType)); void client.invalidateQueries({queryKey: buildsKey}) }}) useEffect(() => { setSelectedId(undefined); setStartOpen(false); setOverrides([]); setBuildspecOverride(defaultBuildspec(projectSourceType)) }, [resource?.id, projectSourceType]) if (!resource) return null const ecrRepository = typeof resource.metadata.ecrRepository === 'string' ? resource.metadata.ecrRepository : undefined @@ -28,7 +28,7 @@ export function CodeBuildPanel({resource, runtimeReachable}: CodeBuildPanelProps {startOpen && ( setStartOpen(false)} onStart={() => start.mutate()}/> )} - {ecrRepository &&

Docker builds push to ECR repository {ecrRepository} and publish `imageDetail.json`, `taskdef.json`, and `appspec.yaml` for CodeDeployToECS.

} + {ecrRepository &&

Docker builds push to local ECR repository {ecrRepository}. For CodeDeployToECS, BuildOutput must include imageDetail.json as {'{"ImageURI":"..."}'}, plus taskdef.json and appspec.yaml.

} {builds.isError &&

{message(builds.error, 'Unable to load builds.')}

}

Build history

{builds.data?.length ?? 0}
{(builds.data ?? []).map((build) => setSelectedId(build.id)} style={{cursor: 'pointer'}}>)}
BuildStatusPhaseStarted
#{build.buildNumber ?? build.id}{build.buildStatus ?? '-'}{build.currentPhase ?? '-'}{formatDateTime(build.startTime) ?? '-'}
{!builds.isLoading && builds.data?.length === 0 &&

No builds yet.

}
{selectedId && } @@ -48,7 +48,7 @@ function BuildDetail({build, loading, error}: {build?: CodeBuildBuild; loading: } function replace(items: T[], index: number, value: T): T[] { return items.map((item, itemIndex) => itemIndex === index ? value : item) } -const DEFAULT_BUILDSPEC = 'version: 0.2\nphases:\n pre_build:\n commands:\n - AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)\n - REPOSITORY_URI=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$ECR_REPOSITORY\n - aws ecr get-login-password | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com\n build:\n commands:\n - IMAGE_TAG=${CODEBUILD_RESOLVED_SOURCE_VERSION:0:7}\n - docker build -t $REPOSITORY_URI:$IMAGE_TAG .\n - docker push $REPOSITORY_URI:$IMAGE_TAG\n post_build:\n commands:\n - printf \'[{"name":"IMAGE1_NAME","imageUri":"%s"}]\' "$REPOSITORY_URI:$IMAGE_TAG" > imageDetail.json\nartifacts:\n files:\n - imageDetail.json\n - taskdef.json\n - appspec.yaml' +export const DEFAULT_BUILDSPEC = 'version: 0.2\nphases:\n install:\n commands:\n - apt-get update && apt-get install -y --no-install-recommends awscli docker.io\n pre_build:\n commands:\n - REPOSITORY_URI=$(aws --endpoint-url "$AWS_ENDPOINT_URL" ecr describe-repositories --repository-names "$ECR_REPOSITORY" --query "repositories[0].repositoryUri" --output text)\n - ECR_REGISTRY=${REPOSITORY_URI%%/*}\n - aws --endpoint-url "$AWS_ENDPOINT_URL" ecr get-login-password | docker login --username AWS --password-stdin "$ECR_REGISTRY"\n build:\n commands:\n - REPOSITORY_URI=$(aws --endpoint-url "$AWS_ENDPOINT_URL" ecr describe-repositories --repository-names "$ECR_REPOSITORY" --query "repositories[0].repositoryUri" --output text)\n - IMAGE_TAG=build-$CODEBUILD_BUILD_NUMBER\n - docker build -t "$REPOSITORY_URI:$IMAGE_TAG" .\n - docker push "$REPOSITORY_URI:$IMAGE_TAG"\n post_build:\n commands:\n - REPOSITORY_URI=$(aws --endpoint-url "$AWS_ENDPOINT_URL" ecr describe-repositories --repository-names "$ECR_REPOSITORY" --query "repositories[0].repositoryUri" --output text)\n - IMAGE_TAG=build-$CODEBUILD_BUILD_NUMBER\n - printf \'{"ImageURI":"%s"}\' "$REPOSITORY_URI:$IMAGE_TAG" > imageDetail.json\nartifacts:\n files:\n - imageDetail.json\n - taskdef.json\n - appspec.yaml' function sourceType(resource?: CloudResource): string | undefined { return typeof resource?.metadata.sourceType === 'string' ? resource.metadata.sourceType : undefined } function defaultBuildspec(sourceType?: string): string { return sourceType === 'S3' ? '' : DEFAULT_BUILDSPEC } function active(status?: string): boolean { return status === 'IN_PROGRESS' } diff --git a/packages/frontend/src/components/CodeDeployPanel.test.tsx b/packages/frontend/src/components/CodeDeployPanel.test.tsx new file mode 100644 index 0000000..09f8576 --- /dev/null +++ b/packages/frontend/src/components/CodeDeployPanel.test.tsx @@ -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() + + 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() + + 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}) + }) +}) diff --git a/packages/frontend/src/components/CodeDeployPanel.tsx b/packages/frontend/src/components/CodeDeployPanel.tsx index 7a9beb9..0038e72 100644 --- a/packages/frontend/src/components/CodeDeployPanel.tsx +++ b/packages/frontend/src/components/CodeDeployPanel.tsx @@ -8,17 +8,32 @@ import type {CloudResource} from '@/types/resource' interface CreateProps { isSubmitting: boolean; submitError: string | null; onCancel: () => void; onSubmit: (values: Record) => void } export function CodeDeployCreatePanel({isSubmitting, submitError, onCancel, onSubmit}: CreateProps) { + const [computePlatform, setComputePlatform] = useState<'Lambda' | 'ECS'>('Lambda') const [applicationName, setApplicationName] = useState('') const [deploymentGroupName, setDeploymentGroupName] = useState('') const [serviceRoleArn, setServiceRoleArn] = useState('') + const [clusterName, setClusterName] = useState('') + const [serviceName, setServiceName] = useState('') + const [blueTargetGroupName, setBlueTargetGroupName] = useState('') + const [greenTargetGroupName, setGreenTargetGroupName] = useState('') + const [productionListenerArn, setProductionListenerArn] = useState('') const [deploymentConfig, setDeploymentConfig] = useState('AllAtOnce') const [rollbackOnFailure, setRollbackOnFailure] = useState(true) - return
{ event.preventDefault(); onSubmit({applicationName, deploymentGroupName, serviceRoleArn, deploymentConfig, rollbackOnFailure}) }}> -

Lambda deployment

Create application and deployment group

The group controls the traffic-shift policy and rollback on deployment failure.

+ const ecs = computePlatform === 'ECS' + return { event.preventDefault(); onSubmit({applicationName, deploymentGroupName, computePlatform, serviceRoleArn, ...(ecs ? {clusterName, serviceName, blueTargetGroupName, greenTargetGroupName, productionListenerArn} : {}), deploymentConfig, rollbackOnFailure}) }}> +

{computePlatform} deployment

Create application and deployment group

The group controls the traffic-shift policy and rollback on deployment failure.

+ - + {ecs && <> + + + + + + } + {submitError &&

{submitError}

}
@@ -43,7 +58,7 @@ export function CodeDeployPanel({resource, runtimeReachable}: {resource?: CloudR if (!resource) return null const ecsServices = Array.isArray(resource.metadata.ecsServices) ? resource.metadata.ecsServices as Array<{clusterName?: string; serviceName?: string}> : [] return
-

{ecsServices.length ? 'ECS EC2 release' : 'Lambda release'}

{ecsServices.length ? 'CodeDeploy deployment status' : 'Deploy a version to an alias'}

{String(resource.metadata.applicationName)} / {resource.name}{ecsServices.length ? ` | ${ecsServices.map((service) => `${service.clusterName}/${service.serviceName}`).join(', ')}` : ''}

+

{ecsServices.length ? 'ECS release' : 'Lambda release'}

{ecsServices.length ? 'CodeDeploy deployment status' : 'Deploy a version to an alias'}

{String(resource.metadata.applicationName)} / {resource.name}{ecsServices.length ? ` | ${ecsServices.map((service) => `${service.clusterName}/${service.serviceName}`).join(', ')}` : ''}

{!ecsServices.length && { event.preventDefault(); start.mutate() }}> diff --git a/packages/frontend/src/components/CodePipelinePanel.tsx b/packages/frontend/src/components/CodePipelinePanel.tsx index 7314a65..b4f0ca7 100644 --- a/packages/frontend/src/components/CodePipelinePanel.tsx +++ b/packages/frontend/src/components/CodePipelinePanel.tsx @@ -15,7 +15,7 @@ export function CodePipelinePanel({resource, runtimeReachable}: CodePipelinePane const start = useMutation({mutationFn: () => startCodePipeline(resource!.id), onSuccess: () => { void client.invalidateQueries({queryKey: executionsKey}); void client.invalidateQueries({queryKey: ['codepipeline-state', resource?.id]}) }}) if (!resource) return null return
-

Pipeline execution

Run and inspect stages

Local actions: S3 source, CodeBuild, and CodeDeployToECS for ECS EC2 services.

+

Pipeline execution

Run and inspect stages

Local actions: S3 source, CodeBuild, and CodeDeployToECS for ECS EC2 and Fargate services.

{start.isError &&

{message(start.error, 'Unable to start pipeline execution.')}

} {(executions.isError || state.isError) &&

{message(executions.error ?? state.error, 'Unable to load pipeline execution state.')}

}

Execution history

{executions.data?.length ?? 0}
{(executions.data ?? []).map((execution) => )}
ExecutionStatusStartedUpdated
{execution.id}{execution.statusSummary &&

{execution.statusSummary}

}
{execution.status ?? '-'}{formatDateTime(execution.startTime) ?? '-'}{formatDateTime(execution.lastUpdateTime) ?? '-'}
{!executions.isLoading && executions.data?.length === 0 &&

No executions yet.

}
diff --git a/packages/frontend/src/components/ComputePanel.tsx b/packages/frontend/src/components/ComputePanel.tsx index 266376b..ba11849 100644 --- a/packages/frontend/src/components/ComputePanel.tsx +++ b/packages/frontend/src/components/ComputePanel.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { CopyValueButton } from './CopyValueButton' import { AlertTriangle, HardDrive, @@ -32,10 +33,12 @@ import { startEc2Instance, stopEc2Instance, terminateEc2Instance, + updateEc2InstanceSecurityGroups, updateEc2InstanceTags, type Ec2Image, type Ec2KeyPair, type Ec2SecurityGroup, + type Ec2SecurityGroupRef, type Ec2Subnet, type Ec2Tag, type Ec2Vpc, @@ -143,6 +146,42 @@ function InlineTagEditor({ instanceId, initialTags }: { instanceId: string; init ) } +function InlineSecurityGroupEditor({ instanceId, vpcId, initialGroups }: { + instanceId: string; vpcId?: string; initialGroups: Ec2SecurityGroupRef[] +}) { + const qc = useQueryClient() + const [selected, setSelected] = useState(() => new Set(initialGroups.map((group) => group.id).filter(Boolean) as string[])) + const groups = useQuery({queryKey: ['ec2', 'security-groups', vpcId], queryFn: ({signal}) => listEc2SecurityGroups(vpcId, signal)}) + const mutation = useMutation({ + mutationFn: () => updateEc2InstanceSecurityGroups(instanceId, [...selected]), + onSuccess: () => { + void qc.invalidateQueries({queryKey: ['ec2', 'instance', instanceId]}) + void qc.invalidateQueries({queryKey: ['ec2', 'instances']}) + void qc.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'compute']}) + }, + }) + const compatibleGroups = (groups.data ?? []).filter((group) => !vpcId || group.vpcId === vpcId) + + return
+ {compatibleGroups.map((group) => )} + {groups.isError &&

Unable to load security groups.

} + {mutation.isError &&

{mutation.error instanceof Error ? mutation.error.message : 'Save failed'}

} + +
+} + // ─── Create AMI modal (inline) ──────────────────────────────────────────────── function CreateAmiModal({ instanceId, instanceName, onClose, onCreated }: { @@ -341,7 +380,7 @@ function DeregisterAmiConfirm({ imageId, onConfirm, onCancel, isPending }: { function InstancePanel({ instanceId }: { instanceId: string }) { const qc = useQueryClient() - const [tab, setTab] = useState<'details' | 'tags' | 'console'>('details') + const [tab, setTab] = useState<'details' | 'connection' | 'securityGroups' | 'tags' | 'console'>('details') const [showAmiModal, setShowAmiModal] = useState(false) const [showConsoleModal, setShowConsoleModal] = useState(false) const [terminateStep, setTerminateStep] = useState<'idle' | 'confirm'>('idle') @@ -510,6 +549,18 @@ function InstancePanel({ instanceId }: { instanceId: string }) { > Details + +