From 8470b8d17a9a67ef4b21a9e38a625eebcb4fb00c Mon Sep 17 00:00:00 2001 From: Zakaria Date: Sat, 26 Sep 2026 16:14:24 -0400 Subject: [PATCH] feat: fully functioning ALB --- README.md | 1 + packages/api/package.json | 1 + .../src/adapter-aws/AwsComputeAdapter.test.ts | 17 +- .../api/src/adapter-aws/AwsComputeAdapter.ts | 37 +-- .../api/src/adapter-aws/AwsEcrAdapter.test.ts | 130 ++++++++++ packages/api/src/adapter-aws/AwsEcrAdapter.ts | 155 ++++++++++++ .../AwsLoadBalancingAdapter.test.ts | 91 +++++-- .../adapter-aws/AwsLoadBalancingAdapter.ts | 124 +++++++++- .../adapter-aws/AwsTargetGroupAdapter.test.ts | 202 ++++++++++++++++ .../src/adapter-aws/AwsTargetGroupAdapter.ts | 224 ++++++++++++++++++ packages/api/src/aws.ts | 4 + packages/api/src/cloud-spi/computeSchema.ts | 21 +- packages/api/src/cloud-spi/ecrSchema.ts | 50 ++++ .../api/src/cloud-spi/loadBalancingSchema.ts | 20 +- packages/api/src/cloud-spi/serviceCatalog.ts | 14 ++ .../api/src/cloud-spi/targetGroupsSchema.ts | 92 +++++++ packages/api/src/cloud-spi/types.ts | 3 + packages/api/src/cloudProxy.ts | 6 +- packages/api/src/routes/clouds.search.test.ts | 10 + packages/api/src/routes/clouds.ts | 13 +- packages/api/src/routes/ec2.test.ts | 1 + packages/api/src/service/CloudProxyService.ts | 6 + packages/api/src/services/ec2.test.ts | 34 +++ packages/api/src/services/ec2.ts | 27 +++ packages/frontend/src/api/api.ts | 38 +++ packages/frontend/src/api/aws/ec2.api.test.ts | 44 +++- packages/frontend/src/api/aws/ec2.api.ts | 21 +- packages/frontend/src/api/cloudProxyClient.ts | 64 ++++- .../frontend/src/components/ComputePanel.tsx | 47 +++- .../src/components/CopyValueButton.tsx | 67 ++++++ .../src/components/DynamicResourceView.tsx | 52 +++- .../src/components/EcrImageBrowser.tsx | 70 ++++++ .../LoadBalancerListenerPanel.test.tsx | 35 +++ .../components/LoadBalancerListenerPanel.tsx | 74 ++++++ .../src/components/LoadBalancerPanel.test.tsx | 40 ++++ .../src/components/LoadBalancerPanel.tsx | 72 ++++++ .../src/components/NetworkingPanel.tsx | 10 +- .../src/components/ResourceInspector.tsx | 19 +- .../frontend/src/components/ResourceTable.tsx | 21 +- .../src/components/TargetGroupPanel.tsx | 81 +++++++ .../frontend/src/components/serviceIcons.ts | 1 + packages/frontend/src/index.css | 83 +++++++ packages/frontend/src/types/schema.ts | 2 + pnpm-lock.yaml | 84 ++++--- 44 files changed, 2076 insertions(+), 132 deletions(-) create mode 100644 packages/api/src/adapter-aws/AwsEcrAdapter.test.ts create mode 100644 packages/api/src/adapter-aws/AwsEcrAdapter.ts create mode 100644 packages/api/src/adapter-aws/AwsTargetGroupAdapter.test.ts create mode 100644 packages/api/src/adapter-aws/AwsTargetGroupAdapter.ts create mode 100644 packages/api/src/cloud-spi/ecrSchema.ts create mode 100644 packages/api/src/cloud-spi/targetGroupsSchema.ts create mode 100644 packages/frontend/src/components/CopyValueButton.tsx create mode 100644 packages/frontend/src/components/EcrImageBrowser.tsx create mode 100644 packages/frontend/src/components/LoadBalancerListenerPanel.test.tsx create mode 100644 packages/frontend/src/components/LoadBalancerListenerPanel.tsx create mode 100644 packages/frontend/src/components/LoadBalancerPanel.test.tsx create mode 100644 packages/frontend/src/components/LoadBalancerPanel.tsx create mode 100644 packages/frontend/src/components/TargetGroupPanel.tsx diff --git a/README.md b/README.md index e257c28..01d0ccf 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ cd packages/api && bun run scripts/service-matrix.ts | Databases | DynamoDB / Cosmos DB NoSQL / NoSQL | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | No | | Networking | Networking | Yes (list) | No | No | | Networking | ELB / Load Balancing | Yes (list, create, delete, inspect) | No | No | +| Networking | ELB Target Groups / Target Groups | Yes (list, create, delete, inspect) | No | No | | Integration | SQS / Messaging / Pub/Sub | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | | Integration | API Gateway | Yes (list, create, delete, inspect) | No | No | | Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No | diff --git a/packages/api/package.json b/packages/api/package.json index f6a9736..fcbc2f2 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -15,6 +15,7 @@ "@aws-sdk/client-cloudformation": "^3.1111.0", "@aws-sdk/client-cloudwatch-logs": "^3.1096.0", "@aws-sdk/client-dynamodb": "^3.1088.0", + "@aws-sdk/client-ecr": "^3.1076.0", "@aws-sdk/client-ec2": "^3.1076.0", "@aws-sdk/client-eks": "^3.1076.0", "@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0", diff --git a/packages/api/src/adapter-aws/AwsComputeAdapter.test.ts b/packages/api/src/adapter-aws/AwsComputeAdapter.test.ts index 597b463..2f69ce5 100644 --- a/packages/api/src/adapter-aws/AwsComputeAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsComputeAdapter.test.ts @@ -18,6 +18,7 @@ const baseInstance: Ec2Instance = { architecture: 'x86_64', platform: undefined, securityGroups: [{id: 'sg-abc', name: 'default'}], + networkInterfaces: [], tags: [{key: 'Name', value: 'web-server-1'}], } @@ -25,14 +26,12 @@ function fakeService(overrides: Partial<{ listInstances: () => Promise describeInstance: (id: string) => Promise terminateInstance: (id: string) => Promise - listAmis: () => Promise<[]> runInstance: (input: RunInstanceInput) => Promise }> = {}) { return { listInstances: async () => [baseInstance], describeInstance: async (_id: string) => baseInstance, terminateInstance: async (_id: string) => {}, - listAmis: async () => [] as [], runInstance: async (_input: RunInstanceInput) => baseInstance, ...overrides, } @@ -80,6 +79,20 @@ describe('AwsComputeAdapter', () => { expect(result).toHaveLength(2) }) + test('list filters instances by their EC2 lifecycle state', async () => { + const adapter = new AwsComputeAdapter(fakeService({ + listInstances: async () => [ + baseInstance, + {...baseInstance, instanceId: 'i-stopped', state: 'stopped'}, + ], + })) + + const result = await adapter.list({state: 'stopped'}) + + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({id: 'i-stopped', status: 'stopped', type: 'instance'}) + }) + test('get returns null when instance has 404 http status', async () => { const err = Object.assign(new Error('Not found'), {$metadata: {httpStatusCode: 404}}) const adapter = new AwsComputeAdapter(fakeService({ diff --git a/packages/api/src/adapter-aws/AwsComputeAdapter.ts b/packages/api/src/adapter-aws/AwsComputeAdapter.ts index 3e4663b..f6926da 100644 --- a/packages/api/src/adapter-aws/AwsComputeAdapter.ts +++ b/packages/api/src/adapter-aws/AwsComputeAdapter.ts @@ -7,13 +7,12 @@ import type { ResourceQuery, ServiceSchema, } from '../cloud-spi/types' -import {ec2Service, type Ec2Image, type Ec2Instance, type RunInstanceInput} from '../services/ec2' +import {ec2Service, type Ec2Instance, type RunInstanceInput} from '../services/ec2' type Ec2ServiceShape = { listInstances(): Promise describeInstance(instanceId: string): Promise terminateInstance(instanceId: string): Promise - listAmis(): Promise runInstance(input: RunInstanceInput): Promise } @@ -28,15 +27,10 @@ export class AwsComputeAdapter implements CloudServiceAdapter { } async list(query: ResourceQuery = {}): Promise { - const [instances, images] = await Promise.all([ - this.service_.listInstances(), - this.service_.listAmis(), - ]) - const resources = [ - ...instances.map(instanceToResource), - ...images.map(imageToResource), - ] - return filterBySearch(resources, query.search) + const resources = (await this.service_.listInstances()).map(instanceToResource) + return filterBySearch(resources, query.search).filter((resource) => + !query.state || resource.status?.toLowerCase() === query.state.toLowerCase(), + ) } async get(id: string): Promise { @@ -105,27 +99,6 @@ function instanceToResource(instance: Ec2Instance): CloudResource { } } -function imageToResource(image: Ec2Image): CloudResource { - return { - id: image.imageId, - name: image.name, - cloud: 'aws', - service: 'compute', - type: 'image', - region: null, - createdAt: image.createdAt ?? null, - status: image.state ?? null, - metadata: { - architecture: image.architecture, - rootDeviceType: image.rootDeviceType, - virtualizationType: image.virtualizationType, - ownerId: image.ownerId, - public: image.public, - tags: image.tags, - }, - } -} - function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { const normalized = search?.trim().toLowerCase() if (!normalized) return resources diff --git a/packages/api/src/adapter-aws/AwsEcrAdapter.test.ts b/packages/api/src/adapter-aws/AwsEcrAdapter.test.ts new file mode 100644 index 0000000..a32c80a --- /dev/null +++ b/packages/api/src/adapter-aws/AwsEcrAdapter.test.ts @@ -0,0 +1,130 @@ +import {describe, expect, test} from 'bun:test' +import { + BatchDeleteImageCommand, + CreateRepositoryCommand, + DeleteRepositoryCommand, + DescribeImagesCommand, + DescribeRepositoriesCommand, + type ECRClient, +} from '@aws-sdk/client-ecr' +import {AwsEcrAdapter} from './AwsEcrAdapter' + +type Command = BatchDeleteImageCommand | CreateRepositoryCommand | DeleteRepositoryCommand | DescribeImagesCommand | DescribeRepositoriesCommand + +function fakeClient(send: (command: Command) => Promise): ECRClient { + return {send} as unknown as ECRClient +} + +describe('AwsEcrAdapter', () => { + test('lists and filters repositories across pages', async () => { + const client = fakeClient(async (command) => { + expect(command).toBeInstanceOf(DescribeRepositoriesCommand) + const input = (command as DescribeRepositoriesCommand).input + return input.nextToken + ? {repositories: [{repositoryName: 'platform/api'}]} + : { + repositories: [{ + repositoryName: 'platform/web', + repositoryArn: 'arn:aws:ecr:eu-west-2:000000000000:repository/platform/web', + repositoryUri: '000000000000.dkr.ecr.eu-west-2.amazonaws.com/platform/web', + createdAt: new Date('2026-09-25T10:00:00.000Z'), + imageTagMutability: 'MUTABLE', + }], + nextToken: 'page-2', + } + }) + + const repositories = await new AwsEcrAdapter(client).list({search: 'api'}) + + expect(repositories).toEqual([expect.objectContaining({ + id: 'platform/api', service: 'ecr', type: 'repository', region: null, + })]) + }) + + test('gets a repository and returns null when it is absent', async () => { + const adapter = new AwsEcrAdapter(fakeClient(async (command) => { + expect(command).toBeInstanceOf(DescribeRepositoriesCommand) + if ((command as DescribeRepositoriesCommand).input.repositoryNames?.[0] === 'missing') { + throw Object.assign(new Error('missing'), {name: 'RepositoryNotFoundException'}) + } + return {repositories: [{repositoryName: 'orders'}]} + })) + + await expect(adapter.get('orders')).resolves.toMatchObject({id: 'orders'}) + await expect(adapter.get('missing')).resolves.toBeNull() + }) + + test('creates and deletes repositories with ECR commands', async () => { + let create: CreateRepositoryCommand | undefined + let deleteCommand: DeleteRepositoryCommand | undefined + const adapter = new AwsEcrAdapter(fakeClient(async (command) => { + if (command instanceof CreateRepositoryCommand) { + create = command + return {repository: {repositoryName: 'platform/api'}} + } + if (command instanceof DeleteRepositoryCommand) { + deleteCommand = command + return {} + } + throw new Error('Unexpected command') + })) + + await expect(adapter.create({values: {repositoryName: 'platform/api'}})).resolves.toMatchObject({id: 'platform/api'}) + await adapter.delete('platform/api') + + expect(create?.input).toEqual({repositoryName: 'platform/api'}) + expect(deleteCommand?.input).toEqual({repositoryName: 'platform/api', force: false}) + }) + + test('rejects invalid repository names before calling ECR', async () => { + let called = false + const adapter = new AwsEcrAdapter(fakeClient(async () => { + called = true + return {} + })) + + await expect(adapter.create({values: {repositoryName: 'Uppercase'}})).rejects.toThrow('Use a valid ECR repository name') + expect(called).toBe(false) + }) + + test('lists image details and deletes an image by digest', async () => { + let deleteCommand: BatchDeleteImageCommand | undefined + const adapter = new AwsEcrAdapter(fakeClient(async (command) => { + if (command instanceof DescribeImagesCommand) { + expect(command.input).toEqual({repositoryName: 'platform/api', nextToken: 'cursor', maxResults: 50}) + return { + imageDetails: [{ + imageDigest: 'sha256:abc123', + imageTags: ['latest', 'v1'], + imageSizeInBytes: 1024, + imagePushedAt: new Date('2026-09-25T10:00:00.000Z'), + }], + nextToken: 'next', + } + } + if (command instanceof BatchDeleteImageCommand) { + deleteCommand = command + return {} + } + throw new Error('Unexpected command') + })) + + const images = await adapter.items.listItems('platform/api', {cursor: 'cursor', limit: 50}) + await adapter.items.deleteItem?.('platform/api', 'sha256:abc123') + + expect(images).toEqual({ + items: [expect.objectContaining({ + id: 'sha256:abc123', timestamp: '2026-09-25T10:00:00.000Z', + body: expect.objectContaining({imageTags: ['latest', 'v1'], imageSizeInBytes: 1024}), + })], + nextCursor: 'next', + }) + expect(deleteCommand?.input).toEqual({repositoryName: 'platform/api', imageIds: [{imageDigest: 'sha256:abc123'}]}) + }) + + test('surfaces ECR batch deletion failures', async () => { + const adapter = new AwsEcrAdapter(fakeClient(async () => ({failures: [{failureReason: 'ImageNotFound'}]}))) + + await expect(adapter.items.deleteItem?.('platform/api', 'sha256:missing')).rejects.toThrow('ImageNotFound') + }) +}) diff --git a/packages/api/src/adapter-aws/AwsEcrAdapter.ts b/packages/api/src/adapter-aws/AwsEcrAdapter.ts new file mode 100644 index 0000000..47733cc --- /dev/null +++ b/packages/api/src/adapter-aws/AwsEcrAdapter.ts @@ -0,0 +1,155 @@ +import { + BatchDeleteImageCommand, + CreateRepositoryCommand, + DeleteRepositoryCommand, + DescribeImagesCommand, + DescribeRepositoriesCommand, + type ECRClient, + type ImageDetail, + type Repository, +} from '@aws-sdk/client-ecr' +import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections' +import {RuntimeError, ValidationError} from '../cloud-spi/errors' +import {ecr as defaultEcr} from '../aws' +import {awsEcrSchema} from '../cloud-spi/ecrSchema' +import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types' + +const ECR_REPOSITORY_NAME = /^(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)*[a-z0-9]+(?:[._-][a-z0-9]+)*$/ + +export class AwsEcrAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'ecr' as const + readonly items: ItemStoreAdapter + + constructor(private readonly ecr: ECRClient = defaultEcr) { + this.items = new EcrImageStore(ecr) + } + + schema(): ServiceSchema { + return awsEcrSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const repositories: Repository[] = [] + let nextToken: string | undefined + do { + const response = await this.ecr.send(new DescribeRepositoriesCommand({nextToken})) + repositories.push(...(response.repositories ?? [])) + nextToken = response.nextToken + } while (nextToken) + + const search = query.search?.trim().toLowerCase() + return repositories + .filter((repository) => !search || repository.repositoryName?.toLowerCase().includes(search)) + .map(toResource) + } + + async get(id: string): Promise { + try { + const response = await this.ecr.send(new DescribeRepositoriesCommand({repositoryNames: [id]})) + const repository = response.repositories?.[0] + return repository ? toResource(repository) : null + } catch (error) { + if (isRepositoryNotFound(error)) return null + throw error + } + } + + async create(input: CreateResourceInput): Promise { + const repositoryName = stringValue(input.values.repositoryName) + if (!repositoryName) throw new ValidationError('repositoryName is required') + if (repositoryName.length < 2 || repositoryName.length > 256 || !ECR_REPOSITORY_NAME.test(repositoryName)) { + throw new ValidationError('Use a valid ECR repository name.') + } + + const response = await this.ecr.send(new CreateRepositoryCommand({repositoryName})) + return toResource(response.repository ?? {repositoryName}) + } + + async delete(id: string): Promise { + await this.ecr.send(new DeleteRepositoryCommand({repositoryName: id, force: false})) + } +} + +class EcrImageStore implements ItemStoreAdapter { + constructor(private readonly ecr: ECRClient) {} + + async listItems(resourceId: string, page: PageQuery = {}): Promise> { + const response = await this.ecr.send(new DescribeImagesCommand({ + repositoryName: resourceId, + nextToken: page.cursor, + maxResults: page.limit, + })) + return { + items: (response.imageDetails ?? []).flatMap(toItem), + nextCursor: response.nextToken ?? null, + } + } + + async deleteItem(resourceId: string, itemId: string): Promise { + const response = await this.ecr.send(new BatchDeleteImageCommand({ + repositoryName: resourceId, + imageIds: [{imageDigest: itemId}], + })) + const failure = response.failures?.[0] + if (failure) throw new RuntimeError(failure.failureReason ?? `Unable to delete image ${itemId}`) + } +} + +function toResource(repository: Repository): CloudResource { + const name = repository.repositoryName ?? '' + return { + id: name, + name, + cloud: 'aws', + service: 'ecr', + type: 'repository', + region: regionFromArn(repository.repositoryArn), + createdAt: repository.createdAt?.toISOString() ?? null, + metadata: { + arn: repository.repositoryArn, + registryId: repository.registryId, + repositoryUri: repository.repositoryUri, + imageTagMutability: repository.imageTagMutability, + imageScanningConfiguration: repository.imageScanningConfiguration, + encryptionConfiguration: repository.encryptionConfiguration, + }, + } +} + +function toItem(image: ImageDetail): ChildItem[] { + if (!image.imageDigest) return [] + return [{ + id: image.imageDigest, + collectionId: null, + timestamp: image.imagePushedAt?.toISOString() ?? null, + body: { + imageDigest: image.imageDigest, + imageTags: image.imageTags ?? [], + imageSizeInBytes: image.imageSizeInBytes ?? null, + imagePushedAt: image.imagePushedAt?.toISOString() ?? null, + imageManifestMediaType: image.imageManifestMediaType ?? null, + artifactMediaType: image.artifactMediaType ?? null, + }, + metadata: { + imageScanStatus: image.imageScanStatus, + imageScanFindingsSummary: image.imageScanFindingsSummary, + registryId: image.registryId, + repositoryName: image.repositoryName, + }, + }] +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function regionFromArn(arn?: string): string | null { + return arn?.split(':')[3] || null +} + +function isRepositoryNotFound(error: unknown): boolean { + return typeof error === 'object' && error !== null + && 'name' in error + && (error as {name?: string}).name === 'RepositoryNotFoundException' +} diff --git a/packages/api/src/adapter-aws/AwsLoadBalancingAdapter.test.ts b/packages/api/src/adapter-aws/AwsLoadBalancingAdapter.test.ts index 9a2425e..1c7f348 100644 --- a/packages/api/src/adapter-aws/AwsLoadBalancingAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsLoadBalancingAdapter.test.ts @@ -1,10 +1,15 @@ import {describe, expect, test} from 'bun:test' import { + CreateListenerCommand, CreateLoadBalancerCommand, + DeleteListenerCommand, DeleteLoadBalancerCommand, + DescribeListenersCommand, DescribeLoadBalancersCommand, + SetSecurityGroupsCommand, type ElasticLoadBalancingV2Client, } from '@aws-sdk/client-elastic-load-balancing-v2' +import {DescribeSecurityGroupsCommand, DescribeSubnetsCommand, DescribeVpcsCommand, type EC2Client} from '@aws-sdk/client-ec2' import {AwsLoadBalancingAdapter} from './AwsLoadBalancingAdapter' import {ValidationError} from '../cloud-spi/errors' @@ -48,11 +53,26 @@ function runtimeStub() { }) } -const validValues = {name: 'web', subnets: 'subnet-default-b, subnet-default-c'} +function stubEc2() { + return { + async send(command: object) { + if (command instanceof DescribeSubnetsCommand) return {Subnets: [ + {SubnetId: 'subnet-default-b', VpcId: 'vpc-default', AvailabilityZone: 'us-east-1b', CidrBlock: '10.0.1.0/24'}, + {SubnetId: 'subnet-default-c', VpcId: 'vpc-default', AvailabilityZone: 'us-east-1c', CidrBlock: '10.0.2.0/24'}, + ]} + if (command instanceof DescribeVpcsCommand) return {Vpcs: [{VpcId: 'vpc-default', CidrBlock: '10.0.0.0/16'}]} + if (command instanceof DescribeSecurityGroupsCommand) return {SecurityGroups: [{GroupId: 'sg-1', VpcId: 'vpc-default', GroupName: 'web'}]} + return {} + }, + } as unknown as EC2Client +} + +const validValues = {name: 'web', subnets: 'subnet-default-b, subnet-default-c', securityGroups: 'sg-1'} +const LISTENER_ARN = 'arn:aws:elasticloadbalancing:us-east-1:000000000000:listener/app/web/77aa388f69144785/aabbccddeeff0011' describe('AwsLoadBalancingAdapter', () => { test('identifies itself as the AWS load balancing adapter', () => { - const adapter = new AwsLoadBalancingAdapter(runtimeStub().client) + const adapter = new AwsLoadBalancingAdapter(runtimeStub().client, stubEc2()) expect(adapter.cloud).toBe('aws') expect(adapter.service).toBe('loadbalancing') @@ -61,7 +81,7 @@ describe('AwsLoadBalancingAdapter', () => { test('lists load balancers and reads the nested state code', async () => { const {client, sent} = runtimeStub() - const [resource] = await new AwsLoadBalancingAdapter(client).list() + const [resource] = await new AwsLoadBalancingAdapter(client, stubEc2()).list() expect(sent[0]).toBeInstanceOf(DescribeLoadBalancersCommand) expect(resource).toMatchObject({ @@ -96,7 +116,7 @@ describe('AwsLoadBalancingAdapter', () => { return {LoadBalancers: [{...balancer, LoadBalancerName: 'api', LoadBalancerArn: `${ARN}-2`}]} }) - const resources = await new AwsLoadBalancingAdapter(client).list() + const resources = await new AwsLoadBalancingAdapter(client, stubEc2()).list() expect(resources.map((r) => r.name)).toEqual(['web', 'api']) expect((sent[1] as DescribeLoadBalancersCommand).input.Marker).toBe('page-2') @@ -112,12 +132,12 @@ describe('AwsLoadBalancingAdapter', () => { return {LoadBalancers: [balancer], NextMarker: ''} }) - await expect(new AwsLoadBalancingAdapter(client).list()).resolves.toHaveLength(1) + await expect(new AwsLoadBalancingAdapter(client, stubEc2()).list()).resolves.toHaveLength(1) expect(call).toBe(1) }) test('filters by name and ARN', async () => { - const adapter = new AwsLoadBalancingAdapter(runtimeStub().client) + const adapter = new AwsLoadBalancingAdapter(runtimeStub().client, stubEc2()) await expect(adapter.list({search: 'we'})).resolves.toHaveLength(1) await expect(adapter.list({search: 'loadbalancer/app'})).resolves.toHaveLength(1) @@ -126,7 +146,7 @@ describe('AwsLoadBalancingAdapter', () => { test('inspects a load balancer by ARN', async () => { const {client, sent} = runtimeStub() - const resource = await new AwsLoadBalancingAdapter(client).get(ARN) + const resource = await new AwsLoadBalancingAdapter(client, stubEc2()).get(ARN) expect((sent[0] as DescribeLoadBalancersCommand).input.LoadBalancerArns).toEqual([ARN]) expect(resource?.id).toBe(ARN) @@ -141,7 +161,7 @@ describe('AwsLoadBalancingAdapter', () => { $metadata: {httpStatusCode: 400}, }) }) - await expect(new AwsLoadBalancingAdapter(client).get(ARN)).resolves.toBeNull() + await expect(new AwsLoadBalancingAdapter(client, stubEc2()).get(ARN)).resolves.toBeNull() }) test('also returns null for the SDK-modelled exception name', async () => { @@ -154,12 +174,12 @@ describe('AwsLoadBalancingAdapter', () => { $metadata: {httpStatusCode: 400}, }) }) - await expect(new AwsLoadBalancingAdapter(client).get(ARN)).resolves.toBeNull() + await expect(new AwsLoadBalancingAdapter(client, stubEc2()).get(ARN)).resolves.toBeNull() }) test('returns null when the runtime answers with no load balancers', async () => { const {client} = stubElb(() => ({LoadBalancers: []})) - await expect(new AwsLoadBalancingAdapter(client).get(ARN)).resolves.toBeNull() + await expect(new AwsLoadBalancingAdapter(client, stubEc2()).get(ARN)).resolves.toBeNull() }) test('rethrows a failure that is not a missing load balancer', async () => { @@ -169,17 +189,18 @@ describe('AwsLoadBalancingAdapter', () => { $metadata: {httpStatusCode: 403}, }) }) - await expect(new AwsLoadBalancingAdapter(client).get(ARN)).rejects.toThrow('AccessDenied') + await expect(new AwsLoadBalancingAdapter(client, stubEc2()).get(ARN)).rejects.toThrow('AccessDenied') }) test('creates a load balancer, splitting the comma separated subnets', async () => { const {client, sent} = runtimeStub() - const resource = await new AwsLoadBalancingAdapter(client).create({values: validValues}) + const resource = await new AwsLoadBalancingAdapter(client, stubEc2()).create({values: validValues}) const command = sent[0] as CreateLoadBalancerCommand expect(command).toBeInstanceOf(CreateLoadBalancerCommand) expect(command.input.Name).toBe('web') expect(command.input.Subnets).toEqual(['subnet-default-b', 'subnet-default-c']) + expect(command.input.SecurityGroups).toEqual(['sg-1']) expect(command.input.Type).toBe('application') expect(command.input.Scheme).toBe('internet-facing') expect(resource.id).toBe(ARN) @@ -188,7 +209,7 @@ describe('AwsLoadBalancingAdapter', () => { test('requires at least two subnet ids', async () => { // Only the count is enforced. The AZ requirement is ELB's, and checking it // would mean calling EC2 from this adapter, so the message must not claim it. - const adapter = new AwsLoadBalancingAdapter(runtimeStub().client) + const adapter = new AwsLoadBalancingAdapter(runtimeStub().client, stubEc2()) await expect(adapter.create({values: {...validValues, subnets: 'subnet-only'}})).rejects.toThrow( new ValidationError('subnets must list at least two subnet ids'), @@ -196,7 +217,7 @@ describe('AwsLoadBalancingAdapter', () => { }) test('requires the fields the schema marks required', async () => { - const adapter = new AwsLoadBalancingAdapter(runtimeStub().client) + const adapter = new AwsLoadBalancingAdapter(runtimeStub().client, stubEc2()) await expect(adapter.create({values: {}})).rejects.toThrow(new ValidationError('name is required')) await expect(adapter.create({values: {name: 'web'}})).rejects.toThrow( @@ -205,19 +226,55 @@ describe('AwsLoadBalancingAdapter', () => { }) test('rejects values the runtime would refuse', async () => { - const adapter = new AwsLoadBalancingAdapter(runtimeStub().client) + const adapter = new AwsLoadBalancingAdapter(runtimeStub().client, stubEc2()) for (const name of ['-web', 'web-', 'has spaces', 'a'.repeat(33)]) { await expect(adapter.create({values: {...validValues, name}}), name).rejects.toThrow(ValidationError) } - await expect(adapter.create({values: {...validValues, type: 'gateway'}})).rejects.toThrow(ValidationError) await expect(adapter.create({values: {...validValues, scheme: 'sideways'}})).rejects.toThrow(ValidationError) }) test('deletes a load balancer by ARN', async () => { const {client, sent} = runtimeStub() - await new AwsLoadBalancingAdapter(client).delete(ARN) + await new AwsLoadBalancingAdapter(client, stubEc2()).delete(ARN) expect((sent[0] as DeleteLoadBalancerCommand).input.LoadBalancerArn).toBe(ARN) }) + + test('returns VPC dependencies from the account-scoped EC2 client', async () => { + const dependencies = await new AwsLoadBalancingAdapter(runtimeStub().client, stubEc2()).dependencies() + + expect(dependencies.vpcs).toEqual([{id: 'vpc-default', cidrBlock: '10.0.0.0/16'}]) + expect(dependencies.subnets).toHaveLength(2) + expect(dependencies.securityGroups).toEqual([{id: 'sg-1', vpcId: 'vpc-default', name: 'web', description: ''}]) + }) + + test('sets security groups and returns the refreshed load balancer', async () => { + const {client, sent} = runtimeStub() + await new AwsLoadBalancingAdapter(client, stubEc2()).update(ARN, {values: {securityGroups: 'sg-1'}}) + + expect(sent[0]).toBeInstanceOf(SetSecurityGroupsCommand) + expect((sent[0] as SetSecurityGroupsCommand).input.SecurityGroups).toEqual(['sg-1']) + }) + + test('manages listeners through the generic flat-item extension', async () => { + const {client, sent} = stubElb((command) => { + if (command instanceof DescribeListenersCommand) { + return {Listeners: [{ListenerArn: LISTENER_ARN, Protocol: 'HTTP', Port: 80, DefaultActions: [{Type: 'forward', TargetGroupArn: 'target-group-arn'}]}]} + } + if (command instanceof CreateListenerCommand) { + return {Listeners: [{ListenerArn: LISTENER_ARN, Protocol: 'HTTP', Port: 8080, DefaultActions: [{Type: 'forward', TargetGroupArn: 'target-group-arn'}]}]} + } + return {} + }) + const items = new AwsLoadBalancingAdapter(client, stubEc2()).items + + await expect(items.listItems(ARN)).resolves.toMatchObject({items: [{id: LISTENER_ARN, body: {protocol: 'HTTP', port: 80, targetGroupArn: 'target-group-arn'}}]}) + await expect(items.putItem!(ARN, {port: '8080', targetGroupArn: 'target-group-arn'})).resolves.toMatchObject({id: LISTENER_ARN}) + await items.deleteItem!(ARN, LISTENER_ARN) + + expect((sent[0] as DescribeListenersCommand).input.LoadBalancerArn).toBe(ARN) + expect((sent[1] as CreateListenerCommand).input).toMatchObject({LoadBalancerArn: ARN, Protocol: 'HTTP', Port: 8080, DefaultActions: [{Type: 'forward', TargetGroupArn: 'target-group-arn'}]}) + expect((sent[2] as DeleteListenerCommand).input.ListenerArn).toBe(LISTENER_ARN) + }) }) diff --git a/packages/api/src/adapter-aws/AwsLoadBalancingAdapter.ts b/packages/api/src/adapter-aws/AwsLoadBalancingAdapter.ts index 06e5553..44a5663 100644 --- a/packages/api/src/adapter-aws/AwsLoadBalancingAdapter.ts +++ b/packages/api/src/adapter-aws/AwsLoadBalancingAdapter.ts @@ -1,12 +1,19 @@ import { + CreateListenerCommand, CreateLoadBalancerCommand, + DeleteListenerCommand, DeleteLoadBalancerCommand, + DescribeListenersCommand, DescribeLoadBalancersCommand, + SetSecurityGroupsCommand, type ElasticLoadBalancingV2Client, + type Listener, type LoadBalancer, } from '@aws-sdk/client-elastic-load-balancing-v2' +import {DescribeSecurityGroupsCommand, DescribeSubnetsCommand, DescribeVpcsCommand, type EC2Client} from '@aws-sdk/client-ec2' +import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections' import {RuntimeError, ValidationError} from '../cloud-spi/errors' -import {ELB_NAME_PATTERN, ELB_SCHEMES, ELB_TYPES, awsLoadBalancingSchema} from '../cloud-spi/loadBalancingSchema' +import {ELB_NAME_PATTERN, ELB_SCHEMES, awsLoadBalancingSchema} from '../cloud-spi/loadBalancingSchema' import type { CloudResource, CloudServiceAdapter, @@ -39,8 +46,11 @@ import type { export class AwsLoadBalancingAdapter implements CloudServiceAdapter { readonly cloud = 'aws' as const readonly service = 'loadbalancing' as const + readonly items: ItemStoreAdapter - constructor(private readonly elb: ElasticLoadBalancingV2Client) {} + constructor(private readonly elb: ElasticLoadBalancingV2Client, private readonly ec2: EC2Client) { + this.items = new ListenerStore(elb) + } schema(): ServiceSchema { return awsLoadBalancingSchema() @@ -78,32 +88,93 @@ export class AwsLoadBalancingAdapter implements CloudServiceAdapter { ) } - // Only the count is enforced. Whether the subnets sit in different - // availability zones is ELB's rule, and checking it here would mean calling - // EC2 DescribeSubnets from the load balancing adapter — real coupling for a - // validation nicety. So the message claims only what this check does; the - // schema field carries the AZ requirement as guidance. const subnets = splitIds(requiredString(input.values.subnets, 'subnets')) - if (subnets.length < 2) { - throw new ValidationError('subnets must list at least two subnet ids') - } - - const type = optionalOneOf(input.values.type, ELB_TYPES, 'type') ?? 'application' + const securityGroups = splitIds(requiredString(input.values.securityGroups, 'securityGroups')) + await this.validateSubnets(subnets) + if (securityGroups.length === 0) throw new ValidationError('securityGroups must list at least one security group id') const scheme = optionalOneOf(input.values.scheme, ELB_SCHEMES, 'scheme') ?? 'internet-facing' const res = await this.elb.send( - new CreateLoadBalancerCommand({Name: name, Subnets: subnets, Type: type, Scheme: scheme}), + new CreateLoadBalancerCommand({Name: name, Subnets: subnets, SecurityGroups: securityGroups, Type: 'application', Scheme: scheme}), ) const created = res.LoadBalancers?.[0] if (!created) throw new RuntimeError(`ELB did not return the created load balancer for ${name}`) return toResource(created) } + async dependencies(): Promise> { + const [vpcs, subnets, securityGroups] = await Promise.all([ + this.ec2.send(new DescribeVpcsCommand({})), + this.ec2.send(new DescribeSubnetsCommand({})), + this.ec2.send(new DescribeSecurityGroupsCommand({})), + ]) + return { + vpcs: (vpcs.Vpcs ?? []).map((vpc) => ({id: vpc.VpcId ?? '', cidrBlock: vpc.CidrBlock ?? ''})), + subnets: (subnets.Subnets ?? []).map((subnet) => ({id: subnet.SubnetId ?? '', vpcId: subnet.VpcId ?? '', availabilityZone: subnet.AvailabilityZone ?? '', cidrBlock: subnet.CidrBlock ?? ''})), + securityGroups: (securityGroups.SecurityGroups ?? []).map((group) => ({id: group.GroupId ?? '', vpcId: group.VpcId ?? '', name: group.GroupName ?? '', description: group.Description ?? ''})), + } + } + + async update(id: string, input: CreateResourceInput): Promise { + const securityGroups = splitIds(requiredString(input.values.securityGroups, 'securityGroups')) + if (securityGroups.length === 0) throw new ValidationError('securityGroups must list at least one security group id') + await this.elb.send(new SetSecurityGroupsCommand({LoadBalancerArn: id, SecurityGroups: securityGroups})) + const updated = await this.get(id) + if (!updated) throw new RuntimeError(`ELB did not return the updated load balancer ${id}`) + return updated + } + + private async validateSubnets(subnetIds: string[]): Promise { + if (subnetIds.length < 2) throw new ValidationError('subnets must list at least two subnet ids') + const res = await this.ec2.send(new DescribeSubnetsCommand({SubnetIds: subnetIds})) + const zones = new Set((res.Subnets ?? []).map((subnet) => subnet.AvailabilityZone).filter(Boolean)) + if (res.Subnets?.length !== subnetIds.length || zones.size < 2) { + throw new ValidationError('subnets must include at least two subnets in distinct availability zones') + } + } + async delete(id: string): Promise { await this.elb.send(new DeleteLoadBalancerCommand({LoadBalancerArn: id})) } } +class ListenerStore implements ItemStoreAdapter { + constructor(private readonly elb: ElasticLoadBalancingV2Client) {} + + async listItems(resourceId: string, _page: PageQuery = {}): Promise> { + const listeners: Listener[] = [] + let marker: string | undefined + + do { + const response = await this.elb.send(new DescribeListenersCommand({LoadBalancerArn: resourceId, ...(marker ? {Marker: marker} : {})})) + listeners.push(...(response.Listeners ?? [])) + marker = response.NextMarker || undefined + } while (marker) + + return {items: listeners.flatMap(toListenerItem), nextCursor: null} + } + + async putItem(resourceId: string, body: Record): Promise { + const port = requiredPort(body.port) + const targetGroupArn = requiredString(body.targetGroupArn, 'targetGroupArn') + const response = await this.elb.send(new CreateListenerCommand({ + LoadBalancerArn: resourceId, + Protocol: 'HTTP', + Port: port, + DefaultActions: [{Type: 'forward', TargetGroupArn: targetGroupArn}], + })) + const listener = response.Listeners?.[0] + if (!listener) throw new RuntimeError(`ELB did not return the listener created on ${resourceId}`) + const item = toListenerItem(listener)[0] + if (!item) throw new RuntimeError(`ELB returned a listener without an ARN for ${resourceId}`) + return item + } + + async deleteItem(_resourceId: string, itemId: string): Promise { + await this.elb.send(new DeleteListenerCommand({ListenerArn: itemId})) + } +} + function toResource(balancer: LoadBalancer): CloudResource { const arn = balancer.LoadBalancerArn ?? '' const zones = balancer.AvailabilityZones ?? [] @@ -134,6 +205,24 @@ function toResource(balancer: LoadBalancer): CloudResource { } } +function toListenerItem(listener: Listener): ChildItem[] { + const arn = listener.ListenerArn + if (!arn) return [] + const action = listener.DefaultActions?.find((candidate) => candidate.Type === 'forward') + return [{ + id: arn, + collectionId: null, + timestamp: null, + body: { + arn, + protocol: listener.Protocol ?? null, + port: listener.Port ?? null, + targetGroupArn: action?.TargetGroupArn ?? null, + }, + metadata: {}, + }] +} + /** `arn:aws:elasticloadbalancing:us-east-1:...` — region is field 4. */ function regionFromArn(arn: string): string | null { return arn.split(':')[3] || null @@ -176,6 +265,15 @@ function requiredString(value: unknown, field: string): string { return trimmed } +function requiredPort(value: unknown): number { + if (typeof value !== 'string' && typeof value !== 'number') throw new ValidationError('port is required') + const port = typeof value === 'number' ? value : Number(value.trim()) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new ValidationError('port must be an integer from 1 to 65535') + } + return port +} + function optionalOneOf(value: unknown, allowed: T, field: string): T[number] | undefined { if (value === undefined || value === null || value === '') return undefined if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`) diff --git a/packages/api/src/adapter-aws/AwsTargetGroupAdapter.test.ts b/packages/api/src/adapter-aws/AwsTargetGroupAdapter.test.ts new file mode 100644 index 0000000..b527d70 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsTargetGroupAdapter.test.ts @@ -0,0 +1,202 @@ +import {describe, expect, test} from 'bun:test' +import { + CreateTargetGroupCommand, + DeregisterTargetsCommand, + DescribeTargetHealthCommand, + DeleteTargetGroupCommand, + DescribeTargetGroupsCommand, + RegisterTargetsCommand, + type ElasticLoadBalancingV2Client, +} from '@aws-sdk/client-elastic-load-balancing-v2' +import {DescribeInstancesCommand, type EC2Client} from '@aws-sdk/client-ec2' +import {AwsTargetGroupAdapter} from './AwsTargetGroupAdapter' +import {ValidationError} from '../cloud-spi/errors' + +type SendResult = Record + +function stubElb(handler: (command: object) => SendResult | Promise) { + const sent: object[] = [] + const client = { + async send(command: object) { + sent.push(command) + return handler(command) + }, + } as unknown as ElasticLoadBalancingV2Client + return {client, sent} +} + +function stubEc2(handler: (command: object) => SendResult | Promise = () => ({})) { + return {async send(command: object) { return handler(command) }} as unknown as EC2Client +} + +const ARN = 'arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/api/77aa388f69144785' +const targetGroup = { + TargetGroupArn: ARN, + TargetGroupName: 'api', + Protocol: 'HTTP', + ProtocolVersion: 'HTTP1', + Port: 8080, + TargetType: 'ip', + VpcId: 'vpc-default', + LoadBalancerArns: ['arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/123'], + HealthCheckProtocol: 'HTTP', + HealthCheckPath: '/health', +} + +function runtimeStub() { + return stubElb((command) => { + if (command instanceof CreateTargetGroupCommand) return {TargetGroups: [targetGroup]} + if (command instanceof DescribeTargetGroupsCommand) return {TargetGroups: [targetGroup]} + return {} + }) +} + +describe('AwsTargetGroupAdapter', () => { + test('identifies itself as the AWS target groups adapter', () => { + const adapter = new AwsTargetGroupAdapter(runtimeStub().client, stubEc2()) + + expect(adapter.cloud).toBe('aws') + expect(adapter.service).toBe('targetgroups') + expect(adapter.schema().displayName).toBe('AWS ELB Target Groups') + }) + + test('lists target groups with their table metadata', async () => { + const {client, sent} = runtimeStub() + const [resource] = await new AwsTargetGroupAdapter(client, stubEc2()).list() + + expect(sent[0]).toBeInstanceOf(DescribeTargetGroupsCommand) + expect(resource).toMatchObject({ + id: ARN, + name: 'api', + cloud: 'aws', + service: 'targetgroups', + type: 'target-group', + region: 'us-east-1', + createdAt: null, + }) + expect(resource?.metadata).toMatchObject({ + protocol: 'HTTP', + port: 8080, + targetType: 'ip', + vpcId: 'vpc-default', + loadBalancerArns: ['arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/123'], + }) + }) + + test('follows a non-empty pagination marker and stops at an empty marker', async () => { + let call = 0 + const {client, sent} = stubElb((command) => { + if (!(command instanceof DescribeTargetGroupsCommand)) return {} + call += 1 + if (call === 1) return {TargetGroups: [targetGroup], NextMarker: 'page-2'} + return {TargetGroups: [{...targetGroup, TargetGroupName: 'worker', TargetGroupArn: `${ARN}-2`}], NextMarker: ''} + }) + + const resources = await new AwsTargetGroupAdapter(client, stubEc2()).list() + + expect(resources.map((resource) => resource.name)).toEqual(['api', 'worker']) + expect((sent[1] as DescribeTargetGroupsCommand).input.Marker).toBe('page-2') + }) + + test('filters by name and ARN', async () => { + const adapter = new AwsTargetGroupAdapter(runtimeStub().client, stubEc2()) + + await expect(adapter.list({search: 'ap'})).resolves.toHaveLength(1) + await expect(adapter.list({search: 'targetgroup/api'})).resolves.toHaveLength(1) + await expect(adapter.list({search: 'nope'})).resolves.toHaveLength(0) + }) + + test('inspects by ARN and returns null for a missing target group', async () => { + const {client, sent} = runtimeStub() + const adapter = new AwsTargetGroupAdapter(client, stubEc2()) + + await expect(adapter.get(ARN)).resolves.toMatchObject({id: ARN}) + expect((sent[0] as DescribeTargetGroupsCommand).input.TargetGroupArns).toEqual([ARN]) + + const missing = new AwsTargetGroupAdapter(stubElb(() => { + throw Object.assign(new Error('One or more target groups not found.'), {name: 'TargetGroupNotFound', $metadata: {httpStatusCode: 400}}) + }).client, stubEc2()) + await expect(missing.get(ARN)).resolves.toBeNull() + }) + + test('creates with required and supported optional SDK fields', async () => { + const {client, sent} = runtimeStub() + const resource = await new AwsTargetGroupAdapter(client, stubEc2()).create({ + values: { + name: 'api', + protocol: 'HTTP', + port: '8080', + targetType: 'ip', + vpcId: 'vpc-default', + healthCheckProtocol: 'HTTP', + healthCheckPath: '/health', + }, + }) + + const command = sent[0] as CreateTargetGroupCommand + expect(command.input).toMatchObject({ + Name: 'api', + Protocol: 'HTTP', + Port: 8080, + TargetType: 'ip', + VpcId: 'vpc-default', + HealthCheckProtocol: 'HTTP', + HealthCheckPath: '/health', + }) + expect(resource.id).toBe(ARN) + }) + + test('requires valid name, protocol, and port', async () => { + const adapter = new AwsTargetGroupAdapter(runtimeStub().client, stubEc2()) + + await expect(adapter.create({values: {}})).rejects.toThrow(new ValidationError('name is required')) + await expect(adapter.create({values: {name: 'api'}})).rejects.toThrow(new ValidationError('protocol is required')) + await expect(adapter.create({values: {name: 'api', protocol: 'HTTP'}})).rejects.toThrow( + new ValidationError('port is required'), + ) + await expect(adapter.create({values: {name: '-api', protocol: 'HTTP', port: 80}})).rejects.toThrow(ValidationError) + await expect(adapter.create({values: {name: 'api', protocol: 'HTTP', port: '70000'}})).rejects.toThrow( + new ValidationError('port must be an integer from 1 to 65535'), + ) + }) + + test('deletes by target group ARN', async () => { + const {client, sent} = runtimeStub() + await new AwsTargetGroupAdapter(client, stubEc2()).delete(ARN) + + expect((sent[0] as DeleteTargetGroupCommand).input.TargetGroupArn).toBe(ARN) + }) + + test('lists running account instances and target health through the AWS SDK', async () => { + const {client, sent} = stubElb((command) => { + if (command instanceof DescribeTargetHealthCommand) return { + TargetHealthDescriptions: [{ + Target: {Id: 'i-123', Port: 8080}, + TargetHealth: {State: 'healthy', Reason: 'Success', Description: 'Health checks passed'}, + }], + } + return {} + }) + const adapter = new AwsTargetGroupAdapter(client, stubEc2((command) => { + if (command instanceof DescribeInstancesCommand) return { + Reservations: [{Instances: [{InstanceId: 'i-123', PrivateIpAddress: '10.0.0.5', Tags: [{Key: 'Name', Value: 'api'}]}]}], + } + return {} + })) + + await expect(adapter.dependencies()).resolves.toEqual({instances: [{id: 'i-123', name: 'api', privateIp: '10.0.0.5'}]}) + await expect(adapter.items.listItems(ARN)).resolves.toMatchObject({items: [{id: 'i-123:8080', body: {state: 'healthy', reason: 'Success'}}]}) + expect((sent[0] as DescribeTargetHealthCommand).input.TargetGroupArn).toBe(ARN) + }) + + test('registers and deregisters targets with the selected port', async () => { + const {client, sent} = stubElb(() => ({})) + const store = new AwsTargetGroupAdapter(client, stubEc2()).items + + await store.putItem?.(ARN, {id: 'i-123', port: '8080'}) + await store.deleteItem?.(ARN, 'i-123:8080') + + expect((sent[0] as RegisterTargetsCommand).input).toEqual({TargetGroupArn: ARN, Targets: [{Id: 'i-123', Port: 8080}]}) + expect((sent[1] as DeregisterTargetsCommand).input).toEqual({TargetGroupArn: ARN, Targets: [{Id: 'i-123', Port: 8080}]}) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsTargetGroupAdapter.ts b/packages/api/src/adapter-aws/AwsTargetGroupAdapter.ts new file mode 100644 index 0000000..d73f192 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsTargetGroupAdapter.ts @@ -0,0 +1,224 @@ +import { + CreateTargetGroupCommand, + DeleteTargetGroupCommand, + DeregisterTargetsCommand, + DescribeTargetHealthCommand, + DescribeTargetGroupsCommand, + RegisterTargetsCommand, + type CreateTargetGroupCommandInput, + type ElasticLoadBalancingV2Client, + type TargetGroup, +} from '@aws-sdk/client-elastic-load-balancing-v2' +import {DescribeInstancesCommand, type EC2Client} from '@aws-sdk/client-ec2' +import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections' +import {RuntimeError, ValidationError} from '../cloud-spi/errors' +import {ELB_NAME_PATTERN} from '../cloud-spi/loadBalancingSchema' +import {awsTargetGroupsSchema} from '../cloud-spi/targetGroupsSchema' +import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types' + +type TargetGroupProtocol = NonNullable +type TargetGroupType = NonNullable + +export class AwsTargetGroupAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'targetgroups' as const + readonly items: ItemStoreAdapter + + constructor(private readonly elb: ElasticLoadBalancingV2Client, private readonly ec2: EC2Client) { + this.items = new TargetHealthStore(elb) + } + + schema(): ServiceSchema { + return awsTargetGroupsSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const groups: TargetGroup[] = [] + let marker: string | undefined + + do { + const response = await this.elb.send(new DescribeTargetGroupsCommand(marker ? {Marker: marker} : {})) + groups.push(...(response.TargetGroups ?? [])) + marker = response.NextMarker || undefined + } while (marker) + + return filterBySearch(groups.map(toResource), query.search) + } + + async get(id: string): Promise { + try { + const response = await this.elb.send(new DescribeTargetGroupsCommand({TargetGroupArns: [id]})) + const group = response.TargetGroups?.[0] + return group ? toResource(group) : null + } catch (error) { + if (isMissing(error)) return null + throw error + } + } + + async create(input: CreateResourceInput): Promise { + const name = requiredString(input.values.name, 'name') + if (!new RegExp(ELB_NAME_PATTERN).test(name)) { + throw new ValidationError( + 'name must be up to 32 characters of letters, digits and hyphens, not starting or ending with a hyphen', + ) + } + + const protocol = requiredString(input.values.protocol, 'protocol') as TargetGroupProtocol + const port = requiredPort(input.values.port) + const response = await this.elb.send(new CreateTargetGroupCommand({ + Name: name, + Protocol: protocol, + Port: port, + TargetType: optionalString(input.values.targetType, 'targetType') as TargetGroupType | undefined, + VpcId: optionalString(input.values.vpcId, 'vpcId'), + HealthCheckProtocol: optionalString(input.values.healthCheckProtocol, 'healthCheckProtocol') as TargetGroupProtocol | undefined, + HealthCheckPath: optionalString(input.values.healthCheckPath, 'healthCheckPath'), + })) + const group = response.TargetGroups?.[0] + if (!group) throw new RuntimeError(`ELB did not return the created target group for ${name}`) + return toResource(group) + } + + async delete(id: string): Promise { + await this.elb.send(new DeleteTargetGroupCommand({TargetGroupArn: id})) + } + + async dependencies(): Promise> { + const instances: Array<{id: string; name: string; privateIp: string | null}> = [] + let nextToken: string | undefined + do { + const response = await this.ec2.send(new DescribeInstancesCommand({ + Filters: [{Name: 'instance-state-name', Values: ['running']}], + NextToken: nextToken, + })) + for (const reservation of response.Reservations ?? []) { + for (const instance of reservation.Instances ?? []) { + if (!instance.InstanceId) continue + instances.push({ + id: instance.InstanceId, + name: instance.Tags?.find((tag) => tag.Key === 'Name')?.Value ?? instance.InstanceId, + privateIp: instance.PrivateIpAddress ?? null, + }) + } + } + nextToken = response.NextToken + } while (nextToken) + return {instances} + } +} + +class TargetHealthStore implements ItemStoreAdapter { + constructor(private readonly elb: ElasticLoadBalancingV2Client) {} + + async listItems(resourceId: string, _page: PageQuery = {}): Promise> { + const response = await this.elb.send(new DescribeTargetHealthCommand({TargetGroupArn: resourceId})) + return {items: (response.TargetHealthDescriptions ?? []).flatMap(toHealthItem), nextCursor: null} + } + + async putItem(resourceId: string, body: Record): Promise { + const id = requiredString(body.id, 'id') + const port = requiredPort(body.port) + await this.elb.send(new RegisterTargetsCommand({TargetGroupArn: resourceId, Targets: [{Id: id, Port: port}]})) + return {id: targetKey(id, port), collectionId: null, timestamp: null, body: {id, port}, metadata: {}} + } + + async deleteItem(resourceId: string, itemId: string): Promise { + const target = parseTargetKey(itemId) + await this.elb.send(new DeregisterTargetsCommand({TargetGroupArn: resourceId, Targets: [target]})) + } +} + +function toResource(group: TargetGroup): CloudResource { + const arn = group.TargetGroupArn ?? '' + return { + id: arn, + name: group.TargetGroupName ?? '', + cloud: 'aws', + service: 'targetgroups', + type: 'target-group', + region: arn.split(':')[3] || null, + createdAt: null, + metadata: { + arn, + protocol: group.Protocol, + protocolVersion: group.ProtocolVersion, + port: group.Port, + targetType: group.TargetType, + vpcId: group.VpcId, + loadBalancerArns: group.LoadBalancerArns, + healthCheckProtocol: group.HealthCheckProtocol, + healthCheckPath: group.HealthCheckPath, + }, + } +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized) || resource.id.toLowerCase().includes(normalized)) +} + +function isMissing(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + const name = (error as {name?: string}).name + return name === 'TargetGroupNotFound' || name === 'TargetGroupNotFoundException' +} + +function requiredString(value: unknown, field: string): string { + if (value === undefined || value === null || value === '') throw new ValidationError(`${field} is required`) + if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`) + const trimmed = value.trim() + if (!trimmed) throw new ValidationError(`${field} is required`) + return trimmed +} + +function optionalString(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`) + const trimmed = value.trim() + return trimmed || undefined +} + +function requiredPort(value: unknown): number { + if (typeof value !== 'string' && typeof value !== 'number') throw new ValidationError('port is required') + const port = typeof value === 'number' ? value : Number(value.trim()) + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new ValidationError('port must be an integer from 1 to 65535') + return port +} + +function toHealthItem(description: {Target?: {Id?: string; Port?: number}; TargetHealth?: {State?: string; Reason?: string; Description?: string}}): ChildItem[] { + const id = description.Target?.Id + if (!id) return [] + const port = description.Target?.Port + return [{ + id: targetKey(id, port), + collectionId: null, + timestamp: null, + body: { + id, + port: port ?? null, + state: description.TargetHealth?.State ?? null, + reason: description.TargetHealth?.Reason ?? null, + description: description.TargetHealth?.Description ?? null, + }, + metadata: {}, + }] +} + +function targetKey(id: string, port?: number): string { + return `${id}:${port ?? ''}` +} + +function parseTargetKey(key: string): {Id: string; Port?: number} { + const separator = key.lastIndexOf(':') + if (separator < 1) { + throw new ValidationError('target id must include a valid port') + } + const id = key.slice(0, separator) + const port = Number(key.slice(separator + 1)) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new ValidationError('target id must include a valid port') + } + return {Id: id, Port: port} +} diff --git a/packages/api/src/aws.ts b/packages/api/src/aws.ts index beed793..609847a 100644 --- a/packages/api/src/aws.ts +++ b/packages/api/src/aws.ts @@ -17,6 +17,7 @@ import { APIGatewayClient } from "@aws-sdk/client-api-gateway"; import { CloudFormationClient } from "@aws-sdk/client-cloudformation"; import { AppConfigClient } from "@aws-sdk/client-appconfig"; import { SNSClient } from "@aws-sdk/client-sns"; +import { ECRClient } from "@aws-sdk/client-ecr"; const endpoint = process.env.FLOCI_ENDPOINT; const region = process.env.AWS_REGION || "us-east-1"; @@ -65,6 +66,7 @@ export type AwsClients = { cloudformation: CloudFormationClient; appConfig: AppConfigClient; sns: SNSClient; + ecr: ECRClient; }; export type AwsClientName = keyof AwsClients; @@ -97,6 +99,7 @@ function buildClients(accountId: string): AwsClients { cloudformation: new CloudFormationClient(base), appConfig: new AppConfigClient(base), sns: new SNSClient(base), + ecr: new ECRClient(base), }; } @@ -134,3 +137,4 @@ export const iam = awsClients.iam; export const dynamodb = awsClients.dynamodb; export const apiGateway = awsClients.apiGateway; export const cloudformation = awsClients.cloudformation; +export const ecr = awsClients.ecr; diff --git a/packages/api/src/cloud-spi/computeSchema.ts b/packages/api/src/cloud-spi/computeSchema.ts index 78941cd..ad35ae2 100644 --- a/packages/api/src/cloud-spi/computeSchema.ts +++ b/packages/api/src/cloud-spi/computeSchema.ts @@ -12,6 +12,25 @@ const computeFilters: FieldSchema[] = [ {name: 'search', label: 'Search', type: 'text', required: false}, ] +const awsComputeFilters: FieldSchema[] = [ + ...computeFilters, + { + name: 'state', + label: 'Lifecycle state', + type: 'select', + required: false, + options: [ + {label: 'All states', value: ''}, + {label: 'Running', value: 'running'}, + {label: 'Stopped', value: 'stopped'}, + {label: 'Pending', value: 'pending'}, + {label: 'Stopping', value: 'stopping'}, + {label: 'Shutting down', value: 'shutting-down'}, + {label: 'Terminated', value: 'terminated'}, + ], + }, +] + const computeFields: FieldSchema[] = [ // ── Required ────────────────────────────────────────────────────── { @@ -234,7 +253,7 @@ export function awsComputeSchema(): ServiceSchema { displayName: 'Compute', fields: computeFields, actions: ['list', 'inspect', 'create', 'delete'], - filters: computeFilters, + filters: awsComputeFilters, columns: computeColumns, capabilities: { resourceActions: [ diff --git a/packages/api/src/cloud-spi/ecrSchema.ts b/packages/api/src/cloud-spi/ecrSchema.ts new file mode 100644 index 0000000..07b18be --- /dev/null +++ b/packages/api/src/cloud-spi/ecrSchema.ts @@ -0,0 +1,50 @@ +import type {ItemActionName} from './childCollections' +import type {CapabilitySchema, FieldSchema, ResourceActionName, ServiceSchema, TableColumnSchema} from './types' + +const resourceActions: CapabilitySchema[] = [ + {name: 'list', label: 'List repositories', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create repository', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete repository', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect repository', enabled: true, status: 'available', runtimeRequired: false}, +] + +const itemActions: CapabilitySchema[] = [ + {name: 'list', label: 'List images', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete image', enabled: true, status: 'available', runtimeRequired: true}, +] + +const fields: FieldSchema[] = [ + { + name: 'repositoryName', + label: 'Repository name', + type: 'text', + required: true, + description: 'Use lowercase letters, numbers, and separators. Names may include namespaces separated by slashes.', + validation: { + pattern: '^(?:[a-z0-9]+(?:[._-][a-z0-9]+)*/)*[a-z0-9]+(?:[._-][a-z0-9]+)*$', + minLength: 2, + maxLength: 256, + message: 'Use a valid ECR repository name.', + }, + }, +] + +const columns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'uri', label: 'URI', path: 'metadata.repositoryUri', format: 'code'}, + {name: 'tagMutability', label: 'Tag mutability', path: 'metadata.imageTagMutability', format: 'badge'}, + {name: 'createdAt', label: 'Created at', format: 'datetime'}, +] + +export function awsEcrSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'ecr', + displayName: 'ECR', + fields, + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: {resourceActions, itemActions}, + filters: [{name: 'search', label: 'Search', type: 'text', required: false}], + columns, + } +} diff --git a/packages/api/src/cloud-spi/loadBalancingSchema.ts b/packages/api/src/cloud-spi/loadBalancingSchema.ts index 8ccc81c..5bdc583 100644 --- a/packages/api/src/cloud-spi/loadBalancingSchema.ts +++ b/packages/api/src/cloud-spi/loadBalancingSchema.ts @@ -11,7 +11,6 @@ const loadBalancerColumns: TableColumnSchema[] = [ const loadBalancerFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}] -export const ELB_TYPES = ['application', 'network'] as const export const ELB_SCHEMES = ['internet-facing', 'internal'] as const /** ELBv2 names: up to 32 chars, alphanumerics and hyphens, no leading/trailing hyphen. */ @@ -48,14 +47,6 @@ export function awsLoadBalancingSchema(): ServiceSchema { description: 'Two or more subnet ids, comma separated — e.g. subnet-aaa, subnet-bbb. ELB requires them to be in different availability zones.', }, - { - name: 'type', - label: 'Type', - type: 'select', - required: false, - description: 'Defaults to application.', - options: ELB_TYPES.map((value) => ({label: value, value})), - }, { name: 'scheme', label: 'Scheme', @@ -65,7 +56,10 @@ export function awsLoadBalancingSchema(): ServiceSchema { options: ELB_SCHEMES.map((value) => ({label: value, value})), }, ], - actions: ['list', 'create', 'delete', 'inspect'], + actions: ['list', 'create', 'update', 'delete', 'inspect'], + updateFields: [ + {name: 'securityGroups', label: 'Security Groups', type: 'text', required: true, valuePath: 'metadata.securityGroups'}, + ], capabilities: { resourceActions: [ {name: 'list', label: 'List load balancers', enabled: true, status: 'available', runtimeRequired: true}, @@ -77,8 +71,14 @@ export function awsLoadBalancingSchema(): ServiceSchema { status: 'available', runtimeRequired: true, }, + {name: 'update', label: 'Set security groups', enabled: true, status: 'available', runtimeRequired: true}, {name: 'delete', label: 'Delete', enabled: true, status: 'available', runtimeRequired: true}, ], + itemActions: [ + {name: 'list', label: 'List listeners', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'put', label: 'Create listener', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete listener', enabled: true, status: 'available', runtimeRequired: true}, + ], }, filters: loadBalancerFilters, columns: loadBalancerColumns, diff --git a/packages/api/src/cloud-spi/serviceCatalog.ts b/packages/api/src/cloud-spi/serviceCatalog.ts index b4c0114..b65bac0 100644 --- a/packages/api/src/cloud-spi/serviceCatalog.ts +++ b/packages/api/src/cloud-spi/serviceCatalog.ts @@ -78,6 +78,13 @@ export const SERVICE_CATALOG = { order: 40, }, storage: {displayName: 'Storage', iconKey: 'storage', group: 'Storage', order: 10}, + ecr: { + displayName: 'Elastic Container Registry', + displayNameByCloud: {aws: 'ECR'}, + iconKey: 'ecr', + group: 'Storage', + order: 20, + }, database: {displayName: 'Database', iconKey: 'database', group: 'Databases', order: 10}, nosql: { displayName: 'NoSQL', @@ -104,6 +111,13 @@ export const SERVICE_CATALOG = { group: 'Networking', order: 20, }, + targetgroups: { + displayName: 'Target Groups', + displayNameByCloud: {aws: 'ELB Target Groups'}, + iconKey: 'loadbalancing', + group: 'Networking', + order: 30, + }, messaging: { displayName: 'Messaging', displayNameByCloud: {aws: 'SQS', gcp: 'Pub/Sub'}, diff --git a/packages/api/src/cloud-spi/targetGroupsSchema.ts b/packages/api/src/cloud-spi/targetGroupsSchema.ts new file mode 100644 index 0000000..31df0f0 --- /dev/null +++ b/packages/api/src/cloud-spi/targetGroupsSchema.ts @@ -0,0 +1,92 @@ +import type {ItemActionName} from './childCollections' +import type {CapabilitySchema, FieldSchema, ServiceSchema, TableColumnSchema} from './types' +import {ELB_NAME_PATTERN} from './loadBalancingSchema' + +const targetGroupColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'protocol', label: 'Protocol', path: 'metadata.protocol', format: 'badge'}, + {name: 'port', label: 'Port', path: 'metadata.port'}, + {name: 'targetType', label: 'Target Type', path: 'metadata.targetType'}, + {name: 'vpcId', label: 'VPC', path: 'metadata.vpcId', format: 'code', emptyText: '—'}, + {name: 'loadBalancerArns', label: 'Attached LBs', path: 'metadata.loadBalancerArns', format: 'list', emptyText: '—'}, +] + +const targetGroupFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}] + +const targetHealthActions: CapabilitySchema[] = [ + {name: 'list', label: 'Describe target health', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'put', label: 'Register target', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Deregister target', enabled: true, status: 'available', runtimeRequired: true}, +] + +const TARGET_GROUP_PROTOCOLS = ['HTTP', 'HTTPS', 'TCP', 'TLS', 'UDP', 'TCP_UDP', 'GENEVE'] +const TARGET_TYPES = ['instance', 'ip', 'lambda', 'alb'] +const HEALTH_CHECK_PROTOCOLS = ['HTTP', 'HTTPS', 'TCP', 'TLS', 'UDP', 'TCP_UDP', 'GENEVE'] + +export function awsTargetGroupsSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'targetgroups', + displayName: 'AWS ELB Target Groups', + fields: [ + { + name: 'name', + label: 'Target Group Name', + type: 'text', + required: true, + group: 'Required', + validation: { + pattern: ELB_NAME_PATTERN, + minLength: 1, + maxLength: 32, + message: 'Up to 32 characters: letters, digits and hyphens, not starting or ending with a hyphen.', + }, + }, + { + name: 'protocol', + label: 'Protocol', + type: 'select', + required: true, + group: 'Required', + options: TARGET_GROUP_PROTOCOLS.map((value) => ({label: value, value})), + }, + { + name: 'port', + label: 'Port', + type: 'text', + required: true, + group: 'Required', + validation: {pattern: '^[1-9][0-9]{0,4}$', message: 'Enter a port from 1 to 65535.'}, + }, + { + name: 'targetType', + label: 'Target Type', + type: 'select', + required: false, + description: 'Defaults to instance.', + options: TARGET_TYPES.map((value) => ({label: value, value})), + }, + {name: 'vpcId', label: 'VPC ID', type: 'text', required: false}, + { + name: 'healthCheckProtocol', + label: 'Health Check Protocol', + type: 'select', + required: false, + options: HEALTH_CHECK_PROTOCOLS.map((value) => ({label: value, value})), + }, + {name: 'healthCheckPath', label: 'Health Check Path', type: 'text', required: false}, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List target groups', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create target group', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete', enabled: true, status: 'available', runtimeRequired: true}, + ], + itemActions: targetHealthActions, + }, + filters: targetGroupFilters, + columns: targetGroupColumns, + } +} diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index 19f3dd6..b67a027 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -433,6 +433,7 @@ export interface AppConfigDeployment { export interface ResourceQuery { search?: string + state?: string } export interface CreateResourceInput { @@ -643,6 +644,8 @@ export interface CloudServiceAdapter { get(id: string): Promise create(input: CreateResourceInput): Promise update?(id: string, input: UpdateResourceInput): Promise + /** Service-specific provider data used to populate resource forms. */ + dependencies?(): Promise> delete(id: string): Promise listObjects?(resourceId: string, prefix?: string): Promise putObject?(resourceId: string, key: string, body: Uint8Array, contentType: string): Promise diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index 08e53da..9ebdb20 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -29,6 +29,7 @@ import {AwsParameterStoreAdapter} from './adapter-aws/AwsParameterStoreAdapter' import {AwsKmsAdapter} from './adapter-aws/AwsKmsAdapter' import {AwsStepFunctionsAdapter} from './adapter-aws/AwsStepFunctionsAdapter' import {AwsLoadBalancingAdapter} from './adapter-aws/AwsLoadBalancingAdapter' +import {AwsTargetGroupAdapter} from './adapter-aws/AwsTargetGroupAdapter' import {AwsEventBridgeAdapter} from './adapter-aws/AwsEventBridgeAdapter' import {AwsIamAdapter} from './adapter-aws/AwsIamAdapter' import {AwsApiGatewayAdapter} from './adapter-aws/AwsApiGatewayAdapter' @@ -37,6 +38,7 @@ import {AwsSecretsAdapter} from './adapter-aws/AwsSecretsAdapter' import {AwsSesAdapter} from './adapter-aws/AwsSesAdapter' import {AwsAppConfigAdapter} from './adapter-aws/AwsAppConfigAdapter' import {AwsSnsAdapter} from './adapter-aws/AwsSnsAdapter' +import {AwsEcrAdapter} from './adapter-aws/AwsEcrAdapter' import {awsClientsForAccount, resolveAccountId} from './aws' import {createEc2Service} from './services/ec2' import {createEksService} from './services/eks' @@ -67,7 +69,8 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap new AwsParameterStoreAdapter(clients.ssm), new AwsKmsAdapter(clients.kms), new AwsStepFunctionsAdapter(clients.sfn), - new AwsLoadBalancingAdapter(clients.elbv2), + new AwsLoadBalancingAdapter(clients.elbv2, clients.ec2), + new AwsTargetGroupAdapter(clients.elbv2, clients.ec2), new AwsEventBridgeAdapter(clients.eventbridge, clients.lambda), new AwsIamAdapter(clients.iam), new AwsApiGatewayAdapter(clients.apiGateway, clients.lambda, resolveAccountId(accountId)), @@ -76,6 +79,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap new AwsSesAdapter(), new AwsAppConfigAdapter(clients.appConfig), new AwsSnsAdapter(clients.sns), + new AwsEcrAdapter(clients.ecr), new AzureStorageAdapter(), new AzureServiceBusAdapter(), new AzureDatabaseAdapter(), diff --git a/packages/api/src/routes/clouds.search.test.ts b/packages/api/src/routes/clouds.search.test.ts index 424c58c..5d0144f 100644 --- a/packages/api/src/routes/clouds.search.test.ts +++ b/packages/api/src/routes/clouds.search.test.ts @@ -81,6 +81,16 @@ describe('GET /clouds/:cloud/services/:service/resources - search query param (# expect(adapter.capturedQuery?.search).toBeUndefined() }) + test('forwards the state param when provided', async () => { + const adapter = new StubStorageAdapter() + const app = buildApp(adapter) + + const response = await app.request('/aws/services/storage/resources?state=running') + + expect(response.status).toBe(200) + expect(adapter.capturedQuery?.state).toBe('running') + }) + test('filters resources by search term via adapter', async () => { const adapter = new StubStorageAdapter() const app = buildApp(adapter) diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index 6b26598..52bf0b1 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -82,6 +82,14 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { return c.json(schema) }) + app.get('/:cloud/services/:service/dependencies', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + const serviceType = c.req.param('service') as CloudServiceType + if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404) + + return withRuntime(c, async () => c.json(await svc(c).dependencies(cloud, serviceType))) + }) + app.get('/:cloud/services/database/snapshots', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) @@ -119,7 +127,10 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404) return withRuntime(c, async () => { - const resources = await svc(c).listResources(cloud, serviceType, {search: c.req.query('search')}) + const resources = await svc(c).listResources(cloud, serviceType, { + search: c.req.query('search'), + state: c.req.query('state'), + }) return c.json(resources) }) }) diff --git a/packages/api/src/routes/ec2.test.ts b/packages/api/src/routes/ec2.test.ts index ccff367..883d3a6 100644 --- a/packages/api/src/routes/ec2.test.ts +++ b/packages/api/src/routes/ec2.test.ts @@ -30,6 +30,7 @@ const baseInstance: Ec2Instance = { instanceType: 't3.micro', availabilityZone: 'us-east-1a', securityGroups: [], + networkInterfaces: [], tags: [], } diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index 4229810..f35d9bd 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -358,6 +358,12 @@ export class CloudProxyService { return adapter.update(id, input) } + async dependencies(cloud: CloudProvider, service: CloudServiceType): Promise> { + const adapter = this.requireAdapter(cloud, service) + if (!adapter.dependencies) throw new NotSupportedError(`Form dependencies are not supported for ${cloud}/${service}`) + return adapter.dependencies() + } + async deleteResource(cloud: CloudProvider, service: CloudServiceType, id: string): Promise { await this.requireAdapter(cloud, service).delete(id) } diff --git a/packages/api/src/services/ec2.test.ts b/packages/api/src/services/ec2.test.ts index 017fbde..ec4768b 100644 --- a/packages/api/src/services/ec2.test.ts +++ b/packages/api/src/services/ec2.test.ts @@ -94,6 +94,40 @@ describe('ec2Service.runInstance', () => { }) }) +describe('ec2Service.describeInstance', () => { + test('maps attached network interfaces and their addresses', async () => { + const service = createEc2Service(fakeClient({ + DescribeInstancesCommand: { + Reservations: [{Instances: [{ + InstanceId: 'i-networked', + NetworkInterfaces: [{ + NetworkInterfaceId: 'eni-123', + Attachment: {DeviceIndex: 0}, + Status: 'in-use', + PrivateIpAddress: '10.0.1.10', + Association: {PublicIp: '198.51.100.10'}, + Groups: [{GroupId: 'sg-123', GroupName: 'web'}], + }], + }]}], + }, + })) + + const instance = await service.describeInstance('i-networked') + + expect(instance.networkInterfaces).toEqual([{ + networkInterfaceId: 'eni-123', + deviceIndex: 0, + status: 'in-use', + privateIpAddress: '10.0.1.10', + publicIpAddress: '198.51.100.10', + privateDnsName: undefined, + subnetId: undefined, + vpcId: undefined, + securityGroups: [{id: 'sg-123', name: 'web'}], + }]) + }) +}) + describe('ec2Service lifecycle', () => { test('startInstance sends StartInstancesCommand with correct id', async () => { const sentCmds: unknown[] = [] diff --git a/packages/api/src/services/ec2.ts b/packages/api/src/services/ec2.ts index 417fb59..9d13279 100644 --- a/packages/api/src/services/ec2.ts +++ b/packages/api/src/services/ec2.ts @@ -77,6 +77,18 @@ export type Ec2SecurityGroupRef = { name?: string } +export type Ec2NetworkInterface = { + networkInterfaceId: string + deviceIndex?: number + status?: string + privateIpAddress?: string + publicIpAddress?: string + privateDnsName?: string + subnetId?: string + vpcId?: string + securityGroups: Ec2SecurityGroupRef[] +} + export type Ec2IpPermission = { protocol: string fromPort: number | null @@ -126,6 +138,7 @@ export type Ec2Instance = { architecture?: string platform?: string securityGroups: Ec2SecurityGroupRef[] + networkInterfaces: Ec2NetworkInterface[] tags: Ec2Tag[] } @@ -306,6 +319,20 @@ function toEc2Instance(instance: Instance): Ec2Instance { id: sg.GroupId, name: sg.GroupName, })), + networkInterfaces: (instance.NetworkInterfaces ?? []).map((networkInterface) => ({ + networkInterfaceId: networkInterface.NetworkInterfaceId ?? '', + deviceIndex: networkInterface.Attachment?.DeviceIndex, + status: networkInterface.Status, + privateIpAddress: networkInterface.PrivateIpAddress, + publicIpAddress: networkInterface.Association?.PublicIp, + privateDnsName: networkInterface.PrivateDnsName, + subnetId: networkInterface.SubnetId, + vpcId: networkInterface.VpcId, + securityGroups: (networkInterface.Groups ?? []).map((group) => ({ + id: group.GroupId, + name: group.GroupName, + })), + })), tags, } } diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index 265137d..e395548 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -15,6 +15,7 @@ export const apiEndpointKeys = { status: "clouds.status.get", serviceStatus: "clouds.services.status.get", schema: "clouds.services.schema.get", + dependencies: "clouds.services.dependencies.get", resources: { list: "clouds.services.resources.list", get: "clouds.services.resources.get", @@ -113,6 +114,11 @@ export const apiEndpointKeys = { list: "clouds.services.childCollections.items.list", }, }, + flatItems: { + list: "clouds.services.flatItems.list", + create: "clouds.services.flatItems.create", + delete: "clouds.services.flatItems.delete", + }, configuration: { environments: { list: "clouds.services.configuration.environments.list", @@ -295,6 +301,14 @@ export const endpointRegistry: EndpointRegistry = new Map([ telemetry: { service: "cloud-proxy" }, }, ], + [ + apiEndpointKeys.clouds.dependencies, + { + path: "/clouds/:cloud/services/:service/dependencies", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], [ apiEndpointKeys.clouds.resources.list, { @@ -890,6 +904,30 @@ export const endpointRegistry: EndpointRegistry = new Map([ telemetry: { service: "cloud-proxy" }, }, ], + [ + apiEndpointKeys.clouds.flatItems.list, + { + path: "/clouds/:cloud/services/:service/resources/:id/items", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.flatItems.create, + { + path: "/clouds/:cloud/services/:service/resources/:id/items", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.flatItems.delete, + { + path: "/clouds/:cloud/services/:service/resources/:id/items/:itemId", + method: "DELETE", + telemetry: { service: "cloud-proxy" }, + }, + ], // AWS EKS [ apiEndpointKeys.aws.eks.clusters.list, diff --git a/packages/frontend/src/api/aws/ec2.api.test.ts b/packages/frontend/src/api/aws/ec2.api.test.ts index 8605bd0..82cdc7f 100644 --- a/packages/frontend/src/api/aws/ec2.api.test.ts +++ b/packages/frontend/src/api/aws/ec2.api.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createEc2SecurityGroup } from "./ec2.api"; +import { + authorizeEc2SecurityGroupEgress, + authorizeEc2SecurityGroupIngress, + createEc2SecurityGroup, + revokeEc2SecurityGroupEgress, + revokeEc2SecurityGroupIngress, +} from "./ec2.api"; describe("createEc2SecurityGroup", () => { const fetchMock = vi.fn(); @@ -42,3 +48,39 @@ describe("createEc2SecurityGroup", () => { }, ); }); + +describe("EC2 security-group rule requests", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + fetchMock.mockReset(); + }); + + it.each([ + ["authorize ingress", authorizeEc2SecurityGroupIngress], + ["revoke ingress", revokeEc2SecurityGroupIngress], + ["authorize egress", authorizeEc2SecurityGroupEgress], + ["revoke egress", revokeEc2SecurityGroupEgress], + ] as const)("sends one JSON permission object for %s", async (_action, request) => { + const permission = { protocol: "tcp", fromPort: 8080, toPort: 8080, cidr: "0.0.0.0/0" }; + + await request("sg-web", permission); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toContain("/api/ec2/security-groups/sg-web"); + expect(JSON.parse(String(init?.body))).toEqual(permission); + expect(new Headers(init?.headers).get("content-type")).toBe("application/json"); + }); +}); diff --git a/packages/frontend/src/api/aws/ec2.api.ts b/packages/frontend/src/api/aws/ec2.api.ts index 68665ef..f14519f 100644 --- a/packages/frontend/src/api/aws/ec2.api.ts +++ b/packages/frontend/src/api/aws/ec2.api.ts @@ -12,6 +12,18 @@ export type Ec2SecurityGroupRef = { name?: string; }; +export type Ec2NetworkInterface = { + networkInterfaceId: string; + deviceIndex?: number; + status?: string; + privateIpAddress?: string; + publicIpAddress?: string; + privateDnsName?: string; + subnetId?: string; + vpcId?: string; + securityGroups: Ec2SecurityGroupRef[]; +}; + export type Ec2IpPermission = { protocol: string; fromPort: number | null; @@ -61,6 +73,7 @@ export type Ec2Instance = { architecture?: string; platform?: string; securityGroups: Ec2SecurityGroupRef[]; + networkInterfaces: Ec2NetworkInterface[]; tags: Ec2Tag[]; }; @@ -391,7 +404,7 @@ export async function authorizeEc2SecurityGroupIngress( ): Promise { await apiClient.call( apiEndpointKeys.aws.ec2.securityGroups.authorize, - { body: JSON.stringify(permission), headers: { "content-type": "application/json" } }, + { body: permission }, { groupId }, ); } @@ -402,7 +415,7 @@ export async function revokeEc2SecurityGroupIngress( ): Promise { await apiClient.call( apiEndpointKeys.aws.ec2.securityGroups.revoke, - { body: JSON.stringify(permission), headers: { "content-type": "application/json" } }, + { body: permission }, { groupId }, ); } @@ -413,7 +426,7 @@ export async function authorizeEc2SecurityGroupEgress( ): Promise { await apiClient.call( apiEndpointKeys.aws.ec2.securityGroups.authorizeEgress, - { body: JSON.stringify(permission), headers: { "content-type": "application/json" } }, + { body: permission }, { groupId }, ); } @@ -424,7 +437,7 @@ export async function revokeEc2SecurityGroupEgress( ): Promise { await apiClient.call( apiEndpointKeys.aws.ec2.securityGroups.revokeEgress, - { body: JSON.stringify(permission), headers: { "content-type": "application/json" } }, + { body: permission }, { groupId }, ); } diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 575efa0..53f2a8f 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -108,15 +108,34 @@ export async function getServiceSchema( return res.data; } +export type LoadBalancingDependencies = { + vpcs: Array<{id: string; cidrBlock: string}>; + subnets: Array<{id: string; vpcId: string; availabilityZone: string; cidrBlock: string}>; + securityGroups: Array<{id: string; vpcId: string; name: string; description: string}>; +}; + +export async function getLoadBalancingDependencies( + service: "loadbalancing" | "targetgroups" = "loadbalancing", + signal?: AbortSignal, +): Promise}> { + const res = await apiClient.call( + apiEndpointKeys.clouds.dependencies, + requestOptions("aws", service, {signal}), + {cloud: "aws", service}, + ); + return res.data; +} + export async function listCloudResources( cloud: CloudProvider, service: CloudServiceType, search?: string, signal?: AbortSignal, + state?: string, ): Promise { const res = await apiClient.call( apiEndpointKeys.clouds.resources.list, - requestOptions(cloud, service, { signal, params: { search } }), + requestOptions(cloud, service, { signal, params: { search, state } }), { cloud, service }, ); return res.data; @@ -905,6 +924,49 @@ export async function listCollectionItems( return res.data; } +export async function listFlatItems( + cloud: CloudProvider, + service: CloudServiceType, + resourceId: string, + cursor?: string, + signal?: AbortSignal, +): Promise> { + const res = await apiClient.call>( + apiEndpointKeys.clouds.flatItems.list, + requestOptions(cloud, service, { signal, params: cursor ? { cursor } : undefined }), + { cloud, service, id: resourceId }, + ); + return res.data; +} + +export async function deleteFlatItem( + cloud: CloudProvider, + service: CloudServiceType, + resourceId: string, + itemId: string, + signal?: AbortSignal, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.flatItems.delete, + requestOptions(cloud, service, { signal }), + { cloud, service, id: resourceId, itemId }, + ); +} + +export async function putFlatItem( + cloud: CloudProvider, + service: CloudServiceType, + resourceId: string, + body: Record, +): Promise { + const res = await apiClient.call>( + apiEndpointKeys.clouds.flatItems.create, + requestOptions(cloud, service, {body}), + {cloud, service, id: resourceId}, + ); + return res.data; +} + export async function listSqlDatabases( cloud: CloudProvider, serverId: string, diff --git a/packages/frontend/src/components/ComputePanel.tsx b/packages/frontend/src/components/ComputePanel.tsx index 1d925a4..266376b 100644 --- a/packages/frontend/src/components/ComputePanel.tsx +++ b/packages/frontend/src/components/ComputePanel.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { AlertTriangle, @@ -583,6 +583,22 @@ function InstancePanel({ instanceId }: { instanceId: string }) { )} + {inst.networkInterfaces.length > 0 && ( +
+ Network interfaces + + {inst.networkInterfaces.map((networkInterface) => ( + + {networkInterface.networkInterfaceId} + {networkInterface.deviceIndex !== undefined && device {networkInterface.deviceIndex}} + {networkInterface.status && {networkInterface.status}} + {networkInterface.privateIpAddress && private {networkInterface.privateIpAddress}} + {networkInterface.publicIpAddress && public {networkInterface.publicIpAddress}} + + ))} + +
+ )} )} @@ -789,6 +805,23 @@ export function LaunchInstanceForm({ const vpcsQ = useQuery({ queryKey: ['ec2', 'vpcs'], queryFn: ({ signal }) => listEc2Vpcs(signal) }) const subsQ = useQuery({ queryKey: ['ec2', 'subnets'], queryFn: ({ signal }) => listEc2Subnets(undefined, signal) }) const sgsQ = useQuery({ queryKey: ['ec2', 'security-groups'], queryFn: ({ signal }) => listEc2SecurityGroups(undefined, signal) }) + const selectedSubnet = (subsQ.data ?? []).find((subnet) => subnet.subnetId === subnetId) + + useEffect(() => { + if (!selectedSubnet) { + return + } + const compatibleGroupIds = new Set((sgsQ.data ?? []) + .filter((group) => group.vpcId === selectedSubnet.vpcId) + .map((group) => group.groupId)) + setSelectedSgs((current) => { + const compatible = current.filter((groupId) => compatibleGroupIds.has(groupId)) + if (compatible.length !== current.length) { + setStepError('Security groups from another VPC were removed. Select groups in the subnet VPC.') + } + return compatible + }) + }, [selectedSubnet, sgsQ.data]) const createMut = useMutation({ mutationFn: (values: Record) => createCloudResource(cloud, 'compute', values), @@ -845,6 +878,13 @@ export function LaunchInstanceForm({ } function launch() { + const hasIncompatibleSecurityGroup = selectedSubnet && selectedSgs.some((groupId) => + !(sgsQ.data ?? []).some((group) => group.groupId === groupId && group.vpcId === selectedSubnet.vpcId)) + if (hasIncompatibleSecurityGroup) { + setStep(3) + setStepError('Security groups must belong to the selected subnet VPC.') + return + } createMut.mutate({ name, imageId, @@ -908,8 +948,8 @@ export function LaunchInstanceForm({ return !quickStartImageIds.has(ami.imageId) && (!normalizedAmiSearch || searchable.includes(normalizedAmiSearch)) }) const selectedAmi = amis.find((ami) => ami.imageId === imageId) - const selectedSubnet = subnets.find((subnet) => subnet.subnetId === subnetId) const selectedGroups = (sgsQ.data ?? []).filter((group) => selectedSgs.includes(group.groupId)) + const availableSecurityGroups = (sgsQ.data ?? []).filter((group) => !selectedSubnet || group.vpcId === selectedSubnet.vpcId) const steps: Array<{ number: LaunchStep; label: string }> = [ { number: 1, label: 'Name and AMI' }, { number: 2, label: 'Instance and key pair' }, @@ -1046,10 +1086,11 @@ export function LaunchInstanceForm({
{sgsQ.isLoading && Loading…} + {selectedSubnet && Showing groups in {selectedSubnet.vpcId} only.} {sgsQ.data?.length === 0 && !showCreateSg && ( No security groups — create one above. )} - {(sgsQ.data ?? []).map((sg: Ec2SecurityGroup) => ( + {availableSecurityGroups.map((sg: Ec2SecurityGroup) => (
+ {schema.filters.find((filter) => filter.name === "state" && filter.type === "select") && ( + + )} {service === "email" && ( clearConfirm ? ( <> @@ -756,6 +774,13 @@ export function DynamicResourceView({ onCancel={() => setCreateOpen(false)} onSubmit={(values) => createMut.mutate(values)} /> + ) : service === "loadbalancing" && cloud === "aws" ? ( + setCreateOpen(false)} + onSubmit={(values) => createMut.mutate(values)} + /> ) : ( )} + {service === "ecr" && cloud === "aws" && ( + + )} + {service === "targetgroups" && cloud === "aws" && ( + + )} + {service === "loadbalancing" && cloud === "aws" && ( + + )} {service === "compute" && ( - setEditingResource(null)} + onSubmit={handleEditSubmit} + /> + ) : setEditingResource(null)} onSubmit={handleEditSubmit} - /> + />} )} diff --git a/packages/frontend/src/components/EcrImageBrowser.tsx b/packages/frontend/src/components/EcrImageBrowser.tsx new file mode 100644 index 0000000..79541e9 --- /dev/null +++ b/packages/frontend/src/components/EcrImageBrowser.tsx @@ -0,0 +1,70 @@ +import {RefreshCw, Trash2} from 'lucide-react' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import {deleteFlatItem, listFlatItems} from '@/api/cloudProxyClient' +import type {CloudResource} from '@/types/resource' + +interface EcrImageBrowserProps { + resource?: CloudResource + runtimeReachable: boolean +} + +export function EcrImageBrowser({resource, runtimeReachable}: EcrImageBrowserProps) { + const queryClient = useQueryClient() + const imagesQuery = useQuery({ + queryKey: ['ecr-images', resource?.id], + queryFn: ({signal}) => listFlatItems('aws', 'ecr', resource?.id ?? '', undefined, signal), + enabled: Boolean(resource) && runtimeReachable, + }) + const deleteMutation = useMutation({ + mutationFn: (imageDigest: string) => deleteFlatItem('aws', 'ecr', resource?.id ?? '', imageDigest), + onSuccess: () => queryClient.invalidateQueries({queryKey: ['ecr-images', resource?.id]}), + }) + + if (!resource) { + return

Select a repository

Choose an ECR repository to browse its images.

+ } + + const images = imagesQuery.data?.items ?? [] + return ( +
+
+
+

Repository images

+ {images.length} images in {resource.name} +
+ +
+ {imagesQuery.error instanceof Error &&

{imagesQuery.error.message}

} + {imagesQuery.isLoading &&
Loading images
} + {imagesQuery.isSuccess && images.length === 0 &&

No images

Push an image to this repository to see it here.

} + {images.length > 0 &&
+ + + {images.map((image) => { + const tags = Array.isArray(image.body.imageTags) ? image.body.imageTags.filter((tag): tag is string => typeof tag === 'string') : [] + const size = typeof image.body.imageSizeInBytes === 'number' ? formatBytes(image.body.imageSizeInBytes) : '-' + return + + + + + + + })} +
TagsDigestPushedSize
{tags.length ? tags.join(', ') : Untagged}{shortDigest(image.id)}{image.timestamp ? new Date(image.timestamp).toLocaleString() : '-'}{size}
+
} +
+ ) +} + +function shortDigest(digest: string): string { + return digest.length > 20 ? `${digest.slice(0, 19)}...` : digest +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MiB` +} diff --git a/packages/frontend/src/components/LoadBalancerListenerPanel.test.tsx b/packages/frontend/src/components/LoadBalancerListenerPanel.test.tsx new file mode 100644 index 0000000..f97ab8c --- /dev/null +++ b/packages/frontend/src/components/LoadBalancerListenerPanel.test.tsx @@ -0,0 +1,35 @@ +import {render, screen} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import {QueryClient, QueryClientProvider} from '@tanstack/react-query' +import {expect, test, vi} from 'vitest' +import {LoadBalancerListenerPanel} from './LoadBalancerListenerPanel' + +const api = vi.hoisted(() => ({ + deleteFlatItem: vi.fn(), + listCloudResources: vi.fn(), + listFlatItems: vi.fn(), + putFlatItem: vi.fn(), +})) + +vi.mock('@/api/cloudProxyClient', () => api) +vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'})) + +test('creates and deletes selected ALB HTTP listeners through generic cloud proxy items', async () => { + const user = userEvent.setup() + api.listCloudResources.mockResolvedValue([{id: 'target-group-arn', name: 'web-targets'}]) + api.listFlatItems.mockResolvedValue({items: [{id: 'listener-arn', body: {protocol: 'HTTP', port: 80, targetGroupArn: 'target-group-arn'}}], nextCursor: null}) + api.putFlatItem.mockResolvedValue({}) + api.deleteFlatItem.mockResolvedValue(undefined) + const client = new QueryClient({defaultOptions: {queries: {retry: false}}}) + render() + + await screen.findByText('web-targets (target-group-arn)') + await user.selectOptions(screen.getByLabelText(/default target group/i), 'target-group-arn') + await user.clear(screen.getByLabelText(/listener port/i)) + await user.type(screen.getByLabelText(/listener port/i), '8080') + await user.click(screen.getByRole('button', {name: /create listener/i})) + expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', {port: 8080, targetGroupArn: 'target-group-arn'}) + + await user.click(screen.getByRole('button', {name: /delete listener 80/i})) + expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', 'listener-arn') +}) diff --git a/packages/frontend/src/components/LoadBalancerListenerPanel.tsx b/packages/frontend/src/components/LoadBalancerListenerPanel.tsx new file mode 100644 index 0000000..ba3d4d0 --- /dev/null +++ b/packages/frontend/src/components/LoadBalancerListenerPanel.tsx @@ -0,0 +1,74 @@ +import {RefreshCw, Trash2} from 'lucide-react' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import {useEffect, useState} from 'react' +import {deleteFlatItem, listCloudResources, listFlatItems, putFlatItem} from '@/api/cloudProxyClient' +import {useAccountId} from '@/lib/accountStore' +import type {CloudResource} from '@/types/resource' + +type Props = { + resource?: CloudResource + runtimeReachable: boolean +} + +export function LoadBalancerListenerPanel({resource, runtimeReachable}: Props) { + const accountId = useAccountId() + const queryClient = useQueryClient() + const [targetGroupArn, setTargetGroupArn] = useState('') + const [port, setPort] = useState('80') + const listenersQuery = useQuery({ + queryKey: ['load-balancer-listeners', accountId, resource?.id], + queryFn: ({signal}) => listFlatItems('aws', 'loadbalancing', resource?.id ?? '', undefined, signal), + enabled: Boolean(resource) && runtimeReachable, + }) + const targetGroupsQuery = useQuery({ + queryKey: ['listener-target-groups', accountId], + queryFn: ({signal}) => listCloudResources('aws', 'targetgroups', undefined, signal), + enabled: Boolean(resource) && runtimeReachable, + }) + + useEffect(() => { + setTargetGroupArn('') + setPort('80') + }, [resource?.id]) + + const createMutation = useMutation({ + mutationFn: () => putFlatItem('aws', 'loadbalancing', resource?.id ?? '', {port: Number(port), targetGroupArn}), + onSuccess: () => queryClient.invalidateQueries({queryKey: ['load-balancer-listeners', accountId, resource?.id]}), + }) + const deleteMutation = useMutation({ + mutationFn: (listenerArn: string) => deleteFlatItem('aws', 'loadbalancing', resource?.id ?? '', listenerArn), + onSuccess: () => queryClient.invalidateQueries({queryKey: ['load-balancer-listeners', accountId, resource?.id]}), + }) + + if (!resource) { + return

Select an Application Load Balancer

Choose a load balancer to create and manage HTTP listeners.

+ } + + const listeners = listenersQuery.data?.items ?? [] + const targetGroups = targetGroupsQuery.data ?? [] + const validPort = /^\d+$/.test(port) && Number(port) >= 1 && Number(port) <= 65535 + const error = listenersQuery.error ?? targetGroupsQuery.error ?? createMutation.error ?? deleteMutation.error + const pending = createMutation.isPending || deleteMutation.isPending + + return
+
+

Listeners

{listeners.length} listeners on {resource.name}
+ +
+
{ event.preventDefault(); if (targetGroupArn && validPort) createMutation.mutate() }}> + + + +
+ {targetGroups.length === 0 && targetGroupsQuery.isSuccess &&

No target groups are available in this account.

} +
+ {error instanceof Error &&

{error.message}

} + {listenersQuery.isLoading &&
Loading listeners
} + {listenersQuery.isSuccess && listeners.length === 0 &&

No listeners

Create an HTTP listener that forwards requests to a target group.

} + {listeners.length > 0 &&
{listeners.map((listener) => )}
ProtocolPortDefault target group
{stringValue(listener.body.protocol)}{stringValue(listener.body.port)}{stringValue(listener.body.targetGroupArn)}
} +
+} + +function stringValue(value: unknown): string { + return typeof value === 'string' || typeof value === 'number' ? String(value) : '' +} diff --git a/packages/frontend/src/components/LoadBalancerPanel.test.tsx b/packages/frontend/src/components/LoadBalancerPanel.test.tsx new file mode 100644 index 0000000..4edb9bd --- /dev/null +++ b/packages/frontend/src/components/LoadBalancerPanel.test.tsx @@ -0,0 +1,40 @@ +import {render, screen} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import {QueryClient, QueryClientProvider} from '@tanstack/react-query' +import {expect, test, vi} from 'vitest' +import {LoadBalancerPanel} from './LoadBalancerPanel' + +vi.mock('@/api/cloudProxyClient', () => ({ + getLoadBalancingDependencies: vi.fn().mockResolvedValue({ + vpcs: [{id: 'vpc-a', cidrBlock: '10.0.0.0/16'}, {id: 'vpc-b', cidrBlock: '10.1.0.0/16'}], + subnets: [ + {id: 'subnet-a', vpcId: 'vpc-a', availabilityZone: 'us-east-1a', cidrBlock: '10.0.1.0/24'}, + {id: 'subnet-b', vpcId: 'vpc-a', availabilityZone: 'us-east-1b', cidrBlock: '10.0.2.0/24'}, + ], + securityGroups: [{id: 'sg-a', vpcId: 'vpc-a', name: 'web', description: ''}], + }), +})) + +test('filters network choices by VPC, resets them, and submits an application load balancer', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn() + render() + + await screen.findByText('vpc-a (10.0.0.0/16)') + await user.type(screen.getByLabelText(/load balancer name/i), 'web') + await user.selectOptions(screen.getByLabelText(/^vpc/i), 'vpc-a') + await user.click(screen.getByLabelText(/subnet-a/i)) + await user.click(screen.getByLabelText(/subnet-b/i)) + await user.click(screen.getByLabelText(/web \(sg-a\)/i)) + await user.selectOptions(screen.getByLabelText(/^vpc/i), 'vpc-b') + expect(screen.queryByLabelText(/subnet-a/i)).not.toBeInTheDocument() + await user.selectOptions(screen.getByLabelText(/^vpc/i), 'vpc-a') + expect(screen.getByLabelText(/subnet-a/i)).not.toBeChecked() + expect(screen.getByLabelText(/web \(sg-a\)/i)).not.toBeChecked() + await user.click(screen.getByLabelText(/subnet-a/i)) + await user.click(screen.getByLabelText(/subnet-b/i)) + await user.click(screen.getByLabelText(/web \(sg-a\)/i)) + await user.click(screen.getByRole('button', {name: /create load balancer/i})) + + expect(onSubmit).toHaveBeenCalledWith({name: 'web', subnets: 'subnet-a,subnet-b', securityGroups: 'sg-a', scheme: 'internet-facing'}) +}) diff --git a/packages/frontend/src/components/LoadBalancerPanel.tsx b/packages/frontend/src/components/LoadBalancerPanel.tsx new file mode 100644 index 0000000..18b520a --- /dev/null +++ b/packages/frontend/src/components/LoadBalancerPanel.tsx @@ -0,0 +1,72 @@ +import {useQuery} from '@tanstack/react-query' +import {FormEvent, useEffect, useState} from 'react' +import {getLoadBalancingDependencies} from '@/api/cloudProxyClient' +import type {CloudResource} from '@/types/resource' + +type Props = { + resource?: CloudResource + isSubmitting: boolean + submitError: string | null + onCancel: () => void + onSubmit: (values: Record) => void +} + +export function LoadBalancerPanel({resource, isSubmitting, submitError, onCancel, onSubmit}: Props) { + const dependencies = useQuery({queryKey: ['loadbalancing-dependencies'], queryFn: ({signal}) => getLoadBalancingDependencies('loadbalancing', signal)}) + const [name, setName] = useState('') + const [scheme, setScheme] = useState('internet-facing') + const [vpcId, setVpcId] = useState('') + const [subnetIds, setSubnetIds] = useState([]) + const [securityGroupIds, setSecurityGroupIds] = useState([]) + const [error, setError] = useState(null) + + useEffect(() => { + if (!resource) return + setVpcId(typeof resource.metadata.vpcId === 'string' ? resource.metadata.vpcId : '') + setSecurityGroupIds(stringValues(resource.metadata.securityGroups)) + }, [resource]) + + const subnets = (dependencies.data?.subnets ?? []).filter((subnet) => subnet.vpcId === vpcId) + const groups = (dependencies.data?.securityGroups ?? []).filter((group) => group.vpcId === vpcId) + const selectedZones = new Set(subnets.filter((subnet) => subnetIds.includes(subnet.id)).map((subnet) => subnet.availabilityZone)) + + function selectVpc(nextVpcId: string) { + setVpcId(nextVpcId) + setSubnetIds([]) + setSecurityGroupIds([]) + setError(null) + } + + function submit(event: FormEvent) { + event.preventDefault() + if (!resource && !name.trim()) return setError('Load balancer name is required.') + if (!resource && (subnetIds.length < 2 || selectedZones.size < 2)) return setError('Select two subnets in distinct Availability Zones.') + if (securityGroupIds.length === 0) return setError('Select at least one security group.') + setError(null) + onSubmit(resource ? {securityGroups: securityGroupIds.join(',')} : { + name: name.trim(), subnets: subnetIds.join(','), securityGroups: securityGroupIds.join(','), scheme, + }) + } + + if (dependencies.isLoading) return

Loading VPC configuration...

+ if (dependencies.isError) return

Unable to load VPC configuration.

+ + return
+
{resource ? 'Security groups' : 'Application Load Balancer'}
+ {!resource && } + {!resource && } + + {!resource &&
Subnets*Select at least two subnets in distinct Availability Zones.{subnets.map((subnet) => )}
} +
Security Groups*{groups.map((group) => )}
+
+ {(error || submitError) &&
{error ?? submitError}
} +
+} + +function toggle(values: string[], value: string): string[] { + return values.includes(value) ? values.filter((entry) => entry !== value) : [...values, value] +} + +function stringValues(value: unknown): string[] { + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [] +} diff --git a/packages/frontend/src/components/NetworkingPanel.tsx b/packages/frontend/src/components/NetworkingPanel.tsx index 2785231..93eba3f 100644 --- a/packages/frontend/src/components/NetworkingPanel.tsx +++ b/packages/frontend/src/components/NetworkingPanel.tsx @@ -229,8 +229,8 @@ function EditRuleTable({rules, onChange}: {rules: EditRule[]; onChange: (rules: No rules. Click Add rule to add one. ) : ( -
- +
+
@@ -248,10 +248,10 @@ function EditRuleTable({rules, onChange}: {rules: EditRule[]; onChange: (rules: const toErr = isCustom(r) && r.toPort !== '' && !isValidPort(r.toPort) return ( - ))} {hasActions && ( @@ -188,3 +189,21 @@ export function ResourceTable({
Type
+ onSelect(resource)}> - {renderColumnValue(getPath(resource, column.path ?? column.name), column)} +
); } + +function CopyableColumnValue({resource, column}: {resource: CloudResource; column: ServiceSchema['columns'][number]}) { + const value = getPath(resource, column.path ?? column.name); + const copyable = typeof value === 'string' && ( + column.format === 'code' || /(^id$|id$|arn|ip|endpoint|url|uri|key)/i.test(column.name) + ); + + if (!copyable) { + return renderColumnValue(value, column); + } + + return ( + + {renderColumnValue(value, column)} + + + ); +} diff --git a/packages/frontend/src/components/TargetGroupPanel.tsx b/packages/frontend/src/components/TargetGroupPanel.tsx new file mode 100644 index 0000000..fd5bd73 --- /dev/null +++ b/packages/frontend/src/components/TargetGroupPanel.tsx @@ -0,0 +1,81 @@ +import {RefreshCw, Trash2} from 'lucide-react' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import {useEffect, useState} from 'react' +import {getLoadBalancingDependencies, listFlatItems, putFlatItem, deleteFlatItem} from '@/api/cloudProxyClient' +import {useAccountId} from '@/lib/accountStore' +import type {CloudResource} from '@/types/resource' + +type Props = { + resource?: CloudResource + runtimeReachable: boolean +} + +type TargetInstance = {id: string; name: string; privateIp: string | null} + +export function TargetGroupPanel({resource, runtimeReachable}: Props) { + const accountId = useAccountId() + const queryClient = useQueryClient() + const [instanceId, setInstanceId] = useState('') + const [port, setPort] = useState('') + const dependenciesQuery = useQuery({ + queryKey: ['target-group-instances', accountId], + queryFn: ({signal}) => getTargetGroupInstances(signal), + enabled: Boolean(resource) && runtimeReachable, + }) + const healthQuery = useQuery({ + queryKey: ['target-group-health', accountId, resource?.id], + queryFn: ({signal}) => listFlatItems('aws', 'targetgroups', resource?.id ?? '', undefined, signal), + enabled: Boolean(resource) && runtimeReachable, + }) + + useEffect(() => { + setInstanceId('') + setPort(typeof resource?.metadata.port === 'number' ? String(resource.metadata.port) : '') + }, [resource?.id, resource?.metadata.port]) + + const refresh = () => { + void Promise.all([dependenciesQuery.refetch(), healthQuery.refetch()]) + } + const registerMutation = useMutation({ + mutationFn: () => putFlatItem('aws', 'targetgroups', resource?.id ?? '', {id: instanceId, port: Number(port)}), + onSuccess: () => queryClient.invalidateQueries({queryKey: ['target-group-health', accountId, resource?.id]}), + }) + const deregisterMutation = useMutation({ + mutationFn: (targetKey: string) => deleteFlatItem('aws', 'targetgroups', resource?.id ?? '', targetKey), + onSuccess: () => queryClient.invalidateQueries({queryKey: ['target-group-health', accountId, resource?.id]}), + }) + + if (!resource) { + return

Select a target group

Choose a target group to register running instances and inspect target health.

+ } + + const instances = dependenciesQuery.data ?? [] + const targets = healthQuery.data?.items ?? [] + const validPort = /^\d+$/.test(port) && Number(port) >= 1 && Number(port) <= 65535 + const error = healthQuery.error ?? dependenciesQuery.error ?? registerMutation.error ?? deregisterMutation.error + return
+
+

Target health

{targets.length} registered targets in {resource.name}
+ +
+
{ event.preventDefault(); if (instanceId && validPort) registerMutation.mutate() }}> + + +
+ {instances.length === 0 && dependenciesQuery.isSuccess &&

No running instances are available in this account.

} +
+ {error instanceof Error &&

{error.message}

} + {healthQuery.isLoading &&
Loading target health
} + {healthQuery.isSuccess && targets.length === 0 &&

No registered targets

Register a running instance to see its ELB health state.

} + {targets.length > 0 &&
{targets.map((target) => )}
InstancePortStateReasonDescription
{stringValue(target.body.id)}{stringValue(target.body.port)}{stringValue(target.body.state) || '-'}{stringValue(target.body.reason) || '-'}{stringValue(target.body.description) || '-'}
} +
+} + +async function getTargetGroupInstances(signal?: AbortSignal): Promise { + const dependencies = await getLoadBalancingDependencies('targetgroups', signal) + return Array.isArray(dependencies.instances) ? dependencies.instances : [] +} + +function stringValue(value: unknown): string { + return typeof value === 'string' || typeof value === 'number' ? String(value) : '' +} diff --git a/packages/frontend/src/components/serviceIcons.ts b/packages/frontend/src/components/serviceIcons.ts index e0d7152..49b0e01 100644 --- a/packages/frontend/src/components/serviceIcons.ts +++ b/packages/frontend/src/components/serviceIcons.ts @@ -33,6 +33,7 @@ import { */ const SERVICE_ICONS: Record = { storage: HardDrive, + ecr: Container, database: Table2, nosql: Database, k8s: Boxes, diff --git a/packages/frontend/src/index.css b/packages/frontend/src/index.css index 4754d99..89b40b4 100644 --- a/packages/frontend/src/index.css +++ b/packages/frontend/src/index.css @@ -188,6 +188,18 @@ button { border: 0; } +.copy-value { + display: inline-flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.copy-value-button { + flex: 0 0 auto; + padding: 2px; +} + #main-content:focus { outline: none; } @@ -4321,6 +4333,77 @@ button.console-service-card { overflow-y: auto; } +.create-table-modal.sg-rules-edit-modal { + box-sizing: border-box; + width: min(980px, calc(100vw - 40px)); + max-width: none; + max-height: min(900px, calc(100vh - 40px)); + overflow-x: hidden; + align-items: stretch; +} + +.sg-rules-edit-modal .modal-section { + box-sizing: border-box; + min-width: 0; +} + +.sg-edit-rule-table-wrap { + width: 100%; + max-width: 100%; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 4px; + margin-bottom: 8px; +} + +.sg-edit-rule-table { + width: 100%; + min-width: 700px; + border-collapse: collapse; + table-layout: fixed; + font-size: 12px; +} + +.sg-edit-rule-table th:nth-child(1) { width: 34%; } +.sg-edit-rule-table th:nth-child(2) { width: 12%; } +.sg-edit-rule-table th:nth-child(3) { width: 24%; } +.sg-edit-rule-table th:nth-child(4) { width: 26%; } +.sg-edit-rule-table th:nth-child(5) { width: 44px; } + +.sg-edit-rule-table td { + overflow: hidden; +} + +.sg-edit-rule-table .sg-edit-type-cell .input, +.sg-edit-rule-table td:nth-child(4) .input { + box-sizing: border-box; + width: 100% !important; + min-width: 0 !important; +} + +.sg-edit-rule-table td:nth-child(3) > div { + gap: 8px !important; +} + +.sg-edit-rule-table td:nth-child(3) .input { + box-sizing: border-box; + width: 64px !important; + min-width: 0 !important; +} + +@media (max-width: 760px) { + .create-table-modal.sg-rules-edit-modal { + width: calc(100vw - 24px); + max-height: calc(100vh - 24px); + padding: 16px; + gap: 12px; + } + + .sg-rules-edit-modal .modal-section { + padding: 10px; + } +} + .create-table-modal h3 { margin: 0; font-size: 15px; diff --git a/packages/frontend/src/types/schema.ts b/packages/frontend/src/types/schema.ts index 88858f6..0813e16 100644 --- a/packages/frontend/src/types/schema.ts +++ b/packages/frontend/src/types/schema.ts @@ -26,6 +26,7 @@ export type KubernetesActionName = | 'listFargateProfiles' | 'createFargateProfile' | 'deleteFargateProfile' +export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query' export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon' export interface CapabilitySchema { @@ -83,6 +84,7 @@ export interface ServiceSchema { objectActions?: Array | ObjectActionName> databaseActions?: Array | DatabaseActionName> kubernetesActions?: Array | KubernetesActionName> + itemActions?: Array | ItemActionName> } filters: FieldSchema[] columns: TableColumnSchema[] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd8e615..964c0d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,9 @@ importers: '@aws-sdk/client-ec2': specifier: ^3.1076.0 version: 3.1076.0 + '@aws-sdk/client-ecr': + specifier: ^3.1076.0 + version: 3.1076.0 '@aws-sdk/client-eks': specifier: ^3.1076.0 version: 3.1076.0 @@ -256,6 +259,10 @@ packages: resolution: {integrity: sha512-6i5gM3QTB6nLU1HjkQG0BIBXtrZ/+ifcv3jDf0dUxvmNSxETZH1i8OujAnBNq2iQ9Kt0xny88cs8rgV9rZeD0g==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-ecr@3.1076.0': + resolution: {integrity: sha512-lZISXKHUDSMMZJonhKeV2h5dWMP2lXwLfhitqdgEE+FziHq4Jx7iW1Vhzx3kJxMMKTisqTADbgO0UCJSd2OxwA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-eks@3.1076.0': resolution: {integrity: sha512-Hqr3hdwUgqWAQbdx6UDy81UBF+jdu3ws6RrykQ4YmBhe/XOKv57lt2lhkjgBuF9fiSw4UUl2XQteks9oxgm4XA==} engines: {node: '>=20.0.0'} @@ -2629,6 +2636,19 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/client-ecr@3.1076.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/credential-provider-node': 3.972.84 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/client-eks@3.1076.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -3079,20 +3099,20 @@ snapshots: '@aws-sdk/credential-provider-login@3.972.76': dependencies: - '@aws-sdk/core': 3.978.0 + '@aws-sdk/core': 3.978.1 '@aws-sdk/nested-clients': 3.997.45 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/credential-provider-login@3.972.77': dependencies: - '@aws-sdk/core': 3.978.0 + '@aws-sdk/core': 3.978.1 '@aws-sdk/nested-clients': 3.997.45 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/credential-provider-login@3.972.78': @@ -3463,24 +3483,24 @@ snapshots: '@aws-sdk/nested-clients@3.997.43': dependencies: - '@aws-sdk/core': 3.978.0 + '@aws-sdk/core': 3.978.1 '@aws-sdk/signature-v4-multi-region': 3.996.45 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/fetch-http-handler': 5.7.2 - '@smithy/node-http-handler': 4.11.3 - '@smithy/types': 4.17.2 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/nested-clients@3.997.44': dependencies: - '@aws-sdk/core': 3.978.0 + '@aws-sdk/core': 3.978.1 '@aws-sdk/signature-v4-multi-region': 3.996.46 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/fetch-http-handler': 5.7.2 - '@smithy/node-http-handler': 4.11.3 - '@smithy/types': 4.17.2 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/nested-clients@3.997.45': @@ -3514,16 +3534,16 @@ snapshots: '@aws-sdk/signature-v4-multi-region@3.996.42': dependencies: - '@aws-sdk/types': 3.974.5 + '@aws-sdk/types': 3.974.6 '@smithy/signature-v4': 5.7.0 - '@smithy/types': 4.17.2 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/signature-v4-multi-region@3.996.45': dependencies: - '@aws-sdk/types': 3.974.5 + '@aws-sdk/types': 3.974.6 '@smithy/signature-v4': 5.7.0 - '@smithy/types': 4.17.2 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/signature-v4-multi-region@3.996.46': @@ -3560,20 +3580,20 @@ snapshots: '@aws-sdk/token-providers@3.1111.0': dependencies: - '@aws-sdk/core': 3.978.0 + '@aws-sdk/core': 3.978.1 '@aws-sdk/nested-clients': 3.997.45 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/token-providers@3.1116.0': dependencies: - '@aws-sdk/core': 3.978.0 + '@aws-sdk/core': 3.978.1 '@aws-sdk/nested-clients': 3.997.45 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/token-providers@3.1129.0':