feat: fully functioning ALB

This commit is contained in:
Zakaria
2026-09-26 16:14:24 -04:00
parent 093afb9b2b
commit 8470b8d17a
44 changed files with 2076 additions and 132 deletions
+1
View File
@@ -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 | | Databases | DynamoDB / Cosmos DB NoSQL / NoSQL | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | No |
| Networking | Networking | Yes (list) | No | No | | Networking | Networking | Yes (list) | No | No |
| Networking | ELB / Load Balancing | Yes (list, create, delete, inspect) | 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 | 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 | API Gateway | Yes (list, create, delete, inspect) | No | No |
| Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No | | Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No |
+1
View File
@@ -15,6 +15,7 @@
"@aws-sdk/client-cloudformation": "^3.1111.0", "@aws-sdk/client-cloudformation": "^3.1111.0",
"@aws-sdk/client-cloudwatch-logs": "^3.1096.0", "@aws-sdk/client-cloudwatch-logs": "^3.1096.0",
"@aws-sdk/client-dynamodb": "^3.1088.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-ec2": "^3.1076.0",
"@aws-sdk/client-eks": "^3.1076.0", "@aws-sdk/client-eks": "^3.1076.0",
"@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0", "@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0",
@@ -18,6 +18,7 @@ const baseInstance: Ec2Instance = {
architecture: 'x86_64', architecture: 'x86_64',
platform: undefined, platform: undefined,
securityGroups: [{id: 'sg-abc', name: 'default'}], securityGroups: [{id: 'sg-abc', name: 'default'}],
networkInterfaces: [],
tags: [{key: 'Name', value: 'web-server-1'}], tags: [{key: 'Name', value: 'web-server-1'}],
} }
@@ -25,14 +26,12 @@ function fakeService(overrides: Partial<{
listInstances: () => Promise<Ec2Instance[]> listInstances: () => Promise<Ec2Instance[]>
describeInstance: (id: string) => Promise<Ec2Instance> describeInstance: (id: string) => Promise<Ec2Instance>
terminateInstance: (id: string) => Promise<void> terminateInstance: (id: string) => Promise<void>
listAmis: () => Promise<[]>
runInstance: (input: RunInstanceInput) => Promise<Ec2Instance> runInstance: (input: RunInstanceInput) => Promise<Ec2Instance>
}> = {}) { }> = {}) {
return { return {
listInstances: async () => [baseInstance], listInstances: async () => [baseInstance],
describeInstance: async (_id: string) => baseInstance, describeInstance: async (_id: string) => baseInstance,
terminateInstance: async (_id: string) => {}, terminateInstance: async (_id: string) => {},
listAmis: async () => [] as [],
runInstance: async (_input: RunInstanceInput) => baseInstance, runInstance: async (_input: RunInstanceInput) => baseInstance,
...overrides, ...overrides,
} }
@@ -80,6 +79,20 @@ describe('AwsComputeAdapter', () => {
expect(result).toHaveLength(2) 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 () => { test('get returns null when instance has 404 http status', async () => {
const err = Object.assign(new Error('Not found'), {$metadata: {httpStatusCode: 404}}) const err = Object.assign(new Error('Not found'), {$metadata: {httpStatusCode: 404}})
const adapter = new AwsComputeAdapter(fakeService({ const adapter = new AwsComputeAdapter(fakeService({
@@ -7,13 +7,12 @@ import type {
ResourceQuery, ResourceQuery,
ServiceSchema, ServiceSchema,
} from '../cloud-spi/types' } 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 = { type Ec2ServiceShape = {
listInstances(): Promise<Ec2Instance[]> listInstances(): Promise<Ec2Instance[]>
describeInstance(instanceId: string): Promise<Ec2Instance> describeInstance(instanceId: string): Promise<Ec2Instance>
terminateInstance(instanceId: string): Promise<void> terminateInstance(instanceId: string): Promise<void>
listAmis(): Promise<Ec2Image[]>
runInstance(input: RunInstanceInput): Promise<Ec2Instance> runInstance(input: RunInstanceInput): Promise<Ec2Instance>
} }
@@ -28,15 +27,10 @@ export class AwsComputeAdapter implements CloudServiceAdapter {
} }
async list(query: ResourceQuery = {}): Promise<CloudResource[]> { async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const [instances, images] = await Promise.all([ const resources = (await this.service_.listInstances()).map(instanceToResource)
this.service_.listInstances(), return filterBySearch(resources, query.search).filter((resource) =>
this.service_.listAmis(), !query.state || resource.status?.toLowerCase() === query.state.toLowerCase(),
]) )
const resources = [
...instances.map(instanceToResource),
...images.map(imageToResource),
]
return filterBySearch(resources, query.search)
} }
async get(id: string): Promise<CloudResource | null> { async get(id: string): Promise<CloudResource | null> {
@@ -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[] { function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase() const normalized = search?.trim().toLowerCase()
if (!normalized) return resources if (!normalized) return resources
@@ -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<unknown>): 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')
})
})
@@ -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<CloudResource[]> {
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<CloudResource | null> {
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<CloudResource> {
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<void> {
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<CollectionPage<ChildItem>> {
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<void> {
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'
}
@@ -1,10 +1,15 @@
import {describe, expect, test} from 'bun:test' import {describe, expect, test} from 'bun:test'
import { import {
CreateListenerCommand,
CreateLoadBalancerCommand, CreateLoadBalancerCommand,
DeleteListenerCommand,
DeleteLoadBalancerCommand, DeleteLoadBalancerCommand,
DescribeListenersCommand,
DescribeLoadBalancersCommand, DescribeLoadBalancersCommand,
SetSecurityGroupsCommand,
type ElasticLoadBalancingV2Client, type ElasticLoadBalancingV2Client,
} from '@aws-sdk/client-elastic-load-balancing-v2' } from '@aws-sdk/client-elastic-load-balancing-v2'
import {DescribeSecurityGroupsCommand, DescribeSubnetsCommand, DescribeVpcsCommand, type EC2Client} from '@aws-sdk/client-ec2'
import {AwsLoadBalancingAdapter} from './AwsLoadBalancingAdapter' import {AwsLoadBalancingAdapter} from './AwsLoadBalancingAdapter'
import {ValidationError} from '../cloud-spi/errors' 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', () => { describe('AwsLoadBalancingAdapter', () => {
test('identifies itself as the AWS load balancing adapter', () => { 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.cloud).toBe('aws')
expect(adapter.service).toBe('loadbalancing') expect(adapter.service).toBe('loadbalancing')
@@ -61,7 +81,7 @@ describe('AwsLoadBalancingAdapter', () => {
test('lists load balancers and reads the nested state code', async () => { test('lists load balancers and reads the nested state code', async () => {
const {client, sent} = runtimeStub() 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(sent[0]).toBeInstanceOf(DescribeLoadBalancersCommand)
expect(resource).toMatchObject({ expect(resource).toMatchObject({
@@ -96,7 +116,7 @@ describe('AwsLoadBalancingAdapter', () => {
return {LoadBalancers: [{...balancer, LoadBalancerName: 'api', LoadBalancerArn: `${ARN}-2`}]} 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(resources.map((r) => r.name)).toEqual(['web', 'api'])
expect((sent[1] as DescribeLoadBalancersCommand).input.Marker).toBe('page-2') expect((sent[1] as DescribeLoadBalancersCommand).input.Marker).toBe('page-2')
@@ -112,12 +132,12 @@ describe('AwsLoadBalancingAdapter', () => {
return {LoadBalancers: [balancer], NextMarker: ''} 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) expect(call).toBe(1)
}) })
test('filters by name and ARN', async () => { 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: 'we'})).resolves.toHaveLength(1)
await expect(adapter.list({search: 'loadbalancer/app'})).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 () => { test('inspects a load balancer by ARN', async () => {
const {client, sent} = runtimeStub() 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((sent[0] as DescribeLoadBalancersCommand).input.LoadBalancerArns).toEqual([ARN])
expect(resource?.id).toBe(ARN) expect(resource?.id).toBe(ARN)
@@ -141,7 +161,7 @@ describe('AwsLoadBalancingAdapter', () => {
$metadata: {httpStatusCode: 400}, $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 () => { test('also returns null for the SDK-modelled exception name', async () => {
@@ -154,12 +174,12 @@ describe('AwsLoadBalancingAdapter', () => {
$metadata: {httpStatusCode: 400}, $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 () => { test('returns null when the runtime answers with no load balancers', async () => {
const {client} = stubElb(() => ({LoadBalancers: []})) 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 () => { test('rethrows a failure that is not a missing load balancer', async () => {
@@ -169,17 +189,18 @@ describe('AwsLoadBalancingAdapter', () => {
$metadata: {httpStatusCode: 403}, $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 () => { test('creates a load balancer, splitting the comma separated subnets', async () => {
const {client, sent} = runtimeStub() 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 const command = sent[0] as CreateLoadBalancerCommand
expect(command).toBeInstanceOf(CreateLoadBalancerCommand) expect(command).toBeInstanceOf(CreateLoadBalancerCommand)
expect(command.input.Name).toBe('web') expect(command.input.Name).toBe('web')
expect(command.input.Subnets).toEqual(['subnet-default-b', 'subnet-default-c']) 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.Type).toBe('application')
expect(command.input.Scheme).toBe('internet-facing') expect(command.input.Scheme).toBe('internet-facing')
expect(resource.id).toBe(ARN) expect(resource.id).toBe(ARN)
@@ -188,7 +209,7 @@ describe('AwsLoadBalancingAdapter', () => {
test('requires at least two subnet ids', async () => { test('requires at least two subnet ids', async () => {
// Only the count is enforced. The AZ requirement is ELB's, and checking it // 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. // 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( await expect(adapter.create({values: {...validValues, subnets: 'subnet-only'}})).rejects.toThrow(
new ValidationError('subnets must list at least two subnet ids'), 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 () => { 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: {}})).rejects.toThrow(new ValidationError('name is required'))
await expect(adapter.create({values: {name: 'web'}})).rejects.toThrow( await expect(adapter.create({values: {name: 'web'}})).rejects.toThrow(
@@ -205,19 +226,55 @@ describe('AwsLoadBalancingAdapter', () => {
}) })
test('rejects values the runtime would refuse', async () => { 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)]) { 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, 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) await expect(adapter.create({values: {...validValues, scheme: 'sideways'}})).rejects.toThrow(ValidationError)
}) })
test('deletes a load balancer by ARN', async () => { test('deletes a load balancer by ARN', async () => {
const {client, sent} = runtimeStub() 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) 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)
})
}) })
@@ -1,12 +1,19 @@
import { import {
CreateListenerCommand,
CreateLoadBalancerCommand, CreateLoadBalancerCommand,
DeleteListenerCommand,
DeleteLoadBalancerCommand, DeleteLoadBalancerCommand,
DescribeListenersCommand,
DescribeLoadBalancersCommand, DescribeLoadBalancersCommand,
SetSecurityGroupsCommand,
type ElasticLoadBalancingV2Client, type ElasticLoadBalancingV2Client,
type Listener,
type LoadBalancer, type LoadBalancer,
} from '@aws-sdk/client-elastic-load-balancing-v2' } 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 {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 { import type {
CloudResource, CloudResource,
CloudServiceAdapter, CloudServiceAdapter,
@@ -39,8 +46,11 @@ import type {
export class AwsLoadBalancingAdapter implements CloudServiceAdapter { export class AwsLoadBalancingAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const readonly cloud = 'aws' as const
readonly service = 'loadbalancing' 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 { schema(): ServiceSchema {
return awsLoadBalancingSchema() 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')) const subnets = splitIds(requiredString(input.values.subnets, 'subnets'))
if (subnets.length < 2) { const securityGroups = splitIds(requiredString(input.values.securityGroups, 'securityGroups'))
throw new ValidationError('subnets must list at least two subnet ids') await this.validateSubnets(subnets)
} if (securityGroups.length === 0) throw new ValidationError('securityGroups must list at least one security group id')
const type = optionalOneOf(input.values.type, ELB_TYPES, 'type') ?? 'application'
const scheme = optionalOneOf(input.values.scheme, ELB_SCHEMES, 'scheme') ?? 'internet-facing' const scheme = optionalOneOf(input.values.scheme, ELB_SCHEMES, 'scheme') ?? 'internet-facing'
const res = await this.elb.send( 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] const created = res.LoadBalancers?.[0]
if (!created) throw new RuntimeError(`ELB did not return the created load balancer for ${name}`) if (!created) throw new RuntimeError(`ELB did not return the created load balancer for ${name}`)
return toResource(created) return toResource(created)
} }
async dependencies(): Promise<Record<string, unknown>> {
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<CloudResource> {
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<void> {
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<void> { async delete(id: string): Promise<void> {
await this.elb.send(new DeleteLoadBalancerCommand({LoadBalancerArn: id})) await this.elb.send(new DeleteLoadBalancerCommand({LoadBalancerArn: id}))
} }
} }
class ListenerStore implements ItemStoreAdapter {
constructor(private readonly elb: ElasticLoadBalancingV2Client) {}
async listItems(resourceId: string, _page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
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<string, unknown>): Promise<ChildItem> {
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<void> {
await this.elb.send(new DeleteListenerCommand({ListenerArn: itemId}))
}
}
function toResource(balancer: LoadBalancer): CloudResource { function toResource(balancer: LoadBalancer): CloudResource {
const arn = balancer.LoadBalancerArn ?? '' const arn = balancer.LoadBalancerArn ?? ''
const zones = balancer.AvailabilityZones ?? [] 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. */ /** `arn:aws:elasticloadbalancing:us-east-1:...` — region is field 4. */
function regionFromArn(arn: string): string | null { function regionFromArn(arn: string): string | null {
return arn.split(':')[3] || null return arn.split(':')[3] || null
@@ -176,6 +265,15 @@ function requiredString(value: unknown, field: string): string {
return trimmed 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<T extends readonly string[]>(value: unknown, allowed: T, field: string): T[number] | undefined { function optionalOneOf<T extends readonly string[]>(value: unknown, allowed: T, field: string): T[number] | undefined {
if (value === undefined || value === null || value === '') return undefined if (value === undefined || value === null || value === '') return undefined
if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`) if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`)
@@ -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<string, unknown>
function stubElb(handler: (command: object) => SendResult | Promise<SendResult>) {
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<SendResult> = () => ({})) {
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}]})
})
})
@@ -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<CreateTargetGroupCommandInput['Protocol']>
type TargetGroupType = NonNullable<CreateTargetGroupCommandInput['TargetType']>
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<CloudResource[]> {
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<CloudResource | null> {
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<CloudResource> {
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<void> {
await this.elb.send(new DeleteTargetGroupCommand({TargetGroupArn: id}))
}
async dependencies(): Promise<Record<string, unknown>> {
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<CollectionPage<ChildItem>> {
const response = await this.elb.send(new DescribeTargetHealthCommand({TargetGroupArn: resourceId}))
return {items: (response.TargetHealthDescriptions ?? []).flatMap(toHealthItem), nextCursor: null}
}
async putItem(resourceId: string, body: Record<string, unknown>): Promise<ChildItem> {
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<void> {
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}
}
+4
View File
@@ -17,6 +17,7 @@ import { APIGatewayClient } from "@aws-sdk/client-api-gateway";
import { CloudFormationClient } from "@aws-sdk/client-cloudformation"; import { CloudFormationClient } from "@aws-sdk/client-cloudformation";
import { AppConfigClient } from "@aws-sdk/client-appconfig"; import { AppConfigClient } from "@aws-sdk/client-appconfig";
import { SNSClient } from "@aws-sdk/client-sns"; import { SNSClient } from "@aws-sdk/client-sns";
import { ECRClient } from "@aws-sdk/client-ecr";
const endpoint = process.env.FLOCI_ENDPOINT; const endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1"; const region = process.env.AWS_REGION || "us-east-1";
@@ -65,6 +66,7 @@ export type AwsClients = {
cloudformation: CloudFormationClient; cloudformation: CloudFormationClient;
appConfig: AppConfigClient; appConfig: AppConfigClient;
sns: SNSClient; sns: SNSClient;
ecr: ECRClient;
}; };
export type AwsClientName = keyof AwsClients; export type AwsClientName = keyof AwsClients;
@@ -97,6 +99,7 @@ function buildClients(accountId: string): AwsClients {
cloudformation: new CloudFormationClient(base), cloudformation: new CloudFormationClient(base),
appConfig: new AppConfigClient(base), appConfig: new AppConfigClient(base),
sns: new SNSClient(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 dynamodb = awsClients.dynamodb;
export const apiGateway = awsClients.apiGateway; export const apiGateway = awsClients.apiGateway;
export const cloudformation = awsClients.cloudformation; export const cloudformation = awsClients.cloudformation;
export const ecr = awsClients.ecr;
+20 -1
View File
@@ -12,6 +12,25 @@ const computeFilters: FieldSchema[] = [
{name: 'search', label: 'Search', type: 'text', required: false}, {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[] = [ const computeFields: FieldSchema[] = [
// ── Required ────────────────────────────────────────────────────── // ── Required ──────────────────────────────────────────────────────
{ {
@@ -234,7 +253,7 @@ export function awsComputeSchema(): ServiceSchema {
displayName: 'Compute', displayName: 'Compute',
fields: computeFields, fields: computeFields,
actions: ['list', 'inspect', 'create', 'delete'], actions: ['list', 'inspect', 'create', 'delete'],
filters: computeFilters, filters: awsComputeFilters,
columns: computeColumns, columns: computeColumns,
capabilities: { capabilities: {
resourceActions: [ resourceActions: [
+50
View File
@@ -0,0 +1,50 @@
import type {ItemActionName} from './childCollections'
import type {CapabilitySchema, FieldSchema, ResourceActionName, ServiceSchema, TableColumnSchema} from './types'
const resourceActions: CapabilitySchema<ResourceActionName>[] = [
{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<ItemActionName>[] = [
{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,
}
}
@@ -11,7 +11,6 @@ const loadBalancerColumns: TableColumnSchema[] = [
const loadBalancerFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}] 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 export const ELB_SCHEMES = ['internet-facing', 'internal'] as const
/** ELBv2 names: up to 32 chars, alphanumerics and hyphens, no leading/trailing hyphen. */ /** ELBv2 names: up to 32 chars, alphanumerics and hyphens, no leading/trailing hyphen. */
@@ -48,14 +47,6 @@ export function awsLoadBalancingSchema(): ServiceSchema {
description: description:
'Two or more subnet ids, comma separated — e.g. subnet-aaa, subnet-bbb. ELB requires them to be in different availability zones.', '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', name: 'scheme',
label: 'Scheme', label: 'Scheme',
@@ -65,7 +56,10 @@ export function awsLoadBalancingSchema(): ServiceSchema {
options: ELB_SCHEMES.map((value) => ({label: value, value})), 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: { capabilities: {
resourceActions: [ resourceActions: [
{name: 'list', label: 'List load balancers', enabled: true, status: 'available', runtimeRequired: true}, {name: 'list', label: 'List load balancers', enabled: true, status: 'available', runtimeRequired: true},
@@ -77,8 +71,14 @@ export function awsLoadBalancingSchema(): ServiceSchema {
status: 'available', status: 'available',
runtimeRequired: true, runtimeRequired: true,
}, },
{name: 'update', label: 'Set security groups', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete', 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, filters: loadBalancerFilters,
columns: loadBalancerColumns, columns: loadBalancerColumns,
@@ -78,6 +78,13 @@ export const SERVICE_CATALOG = {
order: 40, order: 40,
}, },
storage: {displayName: 'Storage', iconKey: 'storage', group: 'Storage', order: 10}, 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}, database: {displayName: 'Database', iconKey: 'database', group: 'Databases', order: 10},
nosql: { nosql: {
displayName: 'NoSQL', displayName: 'NoSQL',
@@ -104,6 +111,13 @@ export const SERVICE_CATALOG = {
group: 'Networking', group: 'Networking',
order: 20, order: 20,
}, },
targetgroups: {
displayName: 'Target Groups',
displayNameByCloud: {aws: 'ELB Target Groups'},
iconKey: 'loadbalancing',
group: 'Networking',
order: 30,
},
messaging: { messaging: {
displayName: 'Messaging', displayName: 'Messaging',
displayNameByCloud: {aws: 'SQS', gcp: 'Pub/Sub'}, displayNameByCloud: {aws: 'SQS', gcp: 'Pub/Sub'},
@@ -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<ItemActionName>[] = [
{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,
}
}
+3
View File
@@ -433,6 +433,7 @@ export interface AppConfigDeployment {
export interface ResourceQuery { export interface ResourceQuery {
search?: string search?: string
state?: string
} }
export interface CreateResourceInput { export interface CreateResourceInput {
@@ -643,6 +644,8 @@ export interface CloudServiceAdapter {
get(id: string): Promise<CloudResource | null> get(id: string): Promise<CloudResource | null>
create(input: CreateResourceInput): Promise<CloudResource> create(input: CreateResourceInput): Promise<CloudResource>
update?(id: string, input: UpdateResourceInput): Promise<CloudResource> update?(id: string, input: UpdateResourceInput): Promise<CloudResource>
/** Service-specific provider data used to populate resource forms. */
dependencies?(): Promise<Record<string, unknown>>
delete(id: string): Promise<void> delete(id: string): Promise<void>
listObjects?(resourceId: string, prefix?: string): Promise<StorageObjectList> listObjects?(resourceId: string, prefix?: string): Promise<StorageObjectList>
putObject?(resourceId: string, key: string, body: Uint8Array, contentType: string): Promise<void> putObject?(resourceId: string, key: string, body: Uint8Array, contentType: string): Promise<void>
+5 -1
View File
@@ -29,6 +29,7 @@ import {AwsParameterStoreAdapter} from './adapter-aws/AwsParameterStoreAdapter'
import {AwsKmsAdapter} from './adapter-aws/AwsKmsAdapter' import {AwsKmsAdapter} from './adapter-aws/AwsKmsAdapter'
import {AwsStepFunctionsAdapter} from './adapter-aws/AwsStepFunctionsAdapter' import {AwsStepFunctionsAdapter} from './adapter-aws/AwsStepFunctionsAdapter'
import {AwsLoadBalancingAdapter} from './adapter-aws/AwsLoadBalancingAdapter' import {AwsLoadBalancingAdapter} from './adapter-aws/AwsLoadBalancingAdapter'
import {AwsTargetGroupAdapter} from './adapter-aws/AwsTargetGroupAdapter'
import {AwsEventBridgeAdapter} from './adapter-aws/AwsEventBridgeAdapter' import {AwsEventBridgeAdapter} from './adapter-aws/AwsEventBridgeAdapter'
import {AwsIamAdapter} from './adapter-aws/AwsIamAdapter' import {AwsIamAdapter} from './adapter-aws/AwsIamAdapter'
import {AwsApiGatewayAdapter} from './adapter-aws/AwsApiGatewayAdapter' import {AwsApiGatewayAdapter} from './adapter-aws/AwsApiGatewayAdapter'
@@ -37,6 +38,7 @@ import {AwsSecretsAdapter} from './adapter-aws/AwsSecretsAdapter'
import {AwsSesAdapter} from './adapter-aws/AwsSesAdapter' import {AwsSesAdapter} from './adapter-aws/AwsSesAdapter'
import {AwsAppConfigAdapter} from './adapter-aws/AwsAppConfigAdapter' import {AwsAppConfigAdapter} from './adapter-aws/AwsAppConfigAdapter'
import {AwsSnsAdapter} from './adapter-aws/AwsSnsAdapter' import {AwsSnsAdapter} from './adapter-aws/AwsSnsAdapter'
import {AwsEcrAdapter} from './adapter-aws/AwsEcrAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws' import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2' import {createEc2Service} from './services/ec2'
import {createEksService} from './services/eks' import {createEksService} from './services/eks'
@@ -67,7 +69,8 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsParameterStoreAdapter(clients.ssm), new AwsParameterStoreAdapter(clients.ssm),
new AwsKmsAdapter(clients.kms), new AwsKmsAdapter(clients.kms),
new AwsStepFunctionsAdapter(clients.sfn), 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 AwsEventBridgeAdapter(clients.eventbridge, clients.lambda),
new AwsIamAdapter(clients.iam), new AwsIamAdapter(clients.iam),
new AwsApiGatewayAdapter(clients.apiGateway, clients.lambda, resolveAccountId(accountId)), new AwsApiGatewayAdapter(clients.apiGateway, clients.lambda, resolveAccountId(accountId)),
@@ -76,6 +79,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsSesAdapter(), new AwsSesAdapter(),
new AwsAppConfigAdapter(clients.appConfig), new AwsAppConfigAdapter(clients.appConfig),
new AwsSnsAdapter(clients.sns), new AwsSnsAdapter(clients.sns),
new AwsEcrAdapter(clients.ecr),
new AzureStorageAdapter(), new AzureStorageAdapter(),
new AzureServiceBusAdapter(), new AzureServiceBusAdapter(),
new AzureDatabaseAdapter(), new AzureDatabaseAdapter(),
@@ -81,6 +81,16 @@ describe('GET /clouds/:cloud/services/:service/resources - search query param (#
expect(adapter.capturedQuery?.search).toBeUndefined() 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 () => { test('filters resources by search term via adapter', async () => {
const adapter = new StubStorageAdapter() const adapter = new StubStorageAdapter()
const app = buildApp(adapter) const app = buildApp(adapter)
+12 -1
View File
@@ -82,6 +82,14 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
return c.json(schema) 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) => { app.get('/:cloud/services/database/snapshots', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) 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) if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => { 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) return c.json(resources)
}) })
}) })
+1
View File
@@ -30,6 +30,7 @@ const baseInstance: Ec2Instance = {
instanceType: 't3.micro', instanceType: 't3.micro',
availabilityZone: 'us-east-1a', availabilityZone: 'us-east-1a',
securityGroups: [], securityGroups: [],
networkInterfaces: [],
tags: [], tags: [],
} }
@@ -358,6 +358,12 @@ export class CloudProxyService {
return adapter.update(id, input) return adapter.update(id, input)
} }
async dependencies(cloud: CloudProvider, service: CloudServiceType): Promise<Record<string, unknown>> {
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<void> { async deleteResource(cloud: CloudProvider, service: CloudServiceType, id: string): Promise<void> {
await this.requireAdapter(cloud, service).delete(id) await this.requireAdapter(cloud, service).delete(id)
} }
+34
View File
@@ -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', () => { describe('ec2Service lifecycle', () => {
test('startInstance sends StartInstancesCommand with correct id', async () => { test('startInstance sends StartInstancesCommand with correct id', async () => {
const sentCmds: unknown[] = [] const sentCmds: unknown[] = []
+27
View File
@@ -77,6 +77,18 @@ export type Ec2SecurityGroupRef = {
name?: string 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 = { export type Ec2IpPermission = {
protocol: string protocol: string
fromPort: number | null fromPort: number | null
@@ -126,6 +138,7 @@ export type Ec2Instance = {
architecture?: string architecture?: string
platform?: string platform?: string
securityGroups: Ec2SecurityGroupRef[] securityGroups: Ec2SecurityGroupRef[]
networkInterfaces: Ec2NetworkInterface[]
tags: Ec2Tag[] tags: Ec2Tag[]
} }
@@ -306,6 +319,20 @@ function toEc2Instance(instance: Instance): Ec2Instance {
id: sg.GroupId, id: sg.GroupId,
name: sg.GroupName, 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, tags,
} }
} }
+38
View File
@@ -15,6 +15,7 @@ export const apiEndpointKeys = {
status: "clouds.status.get", status: "clouds.status.get",
serviceStatus: "clouds.services.status.get", serviceStatus: "clouds.services.status.get",
schema: "clouds.services.schema.get", schema: "clouds.services.schema.get",
dependencies: "clouds.services.dependencies.get",
resources: { resources: {
list: "clouds.services.resources.list", list: "clouds.services.resources.list",
get: "clouds.services.resources.get", get: "clouds.services.resources.get",
@@ -113,6 +114,11 @@ export const apiEndpointKeys = {
list: "clouds.services.childCollections.items.list", list: "clouds.services.childCollections.items.list",
}, },
}, },
flatItems: {
list: "clouds.services.flatItems.list",
create: "clouds.services.flatItems.create",
delete: "clouds.services.flatItems.delete",
},
configuration: { configuration: {
environments: { environments: {
list: "clouds.services.configuration.environments.list", list: "clouds.services.configuration.environments.list",
@@ -295,6 +301,14 @@ export const endpointRegistry: EndpointRegistry = new Map([
telemetry: { service: "cloud-proxy" }, telemetry: { service: "cloud-proxy" },
}, },
], ],
[
apiEndpointKeys.clouds.dependencies,
{
path: "/clouds/:cloud/services/:service/dependencies",
method: "GET",
telemetry: { service: "cloud-proxy" },
},
],
[ [
apiEndpointKeys.clouds.resources.list, apiEndpointKeys.clouds.resources.list,
{ {
@@ -890,6 +904,30 @@ export const endpointRegistry: EndpointRegistry = new Map([
telemetry: { service: "cloud-proxy" }, 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 // AWS EKS
[ [
apiEndpointKeys.aws.eks.clusters.list, apiEndpointKeys.aws.eks.clusters.list,
+43 -1
View File
@@ -1,5 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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", () => { describe("createEc2SecurityGroup", () => {
const fetchMock = vi.fn<typeof fetch>(); const fetchMock = vi.fn<typeof fetch>();
@@ -42,3 +48,39 @@ describe("createEc2SecurityGroup", () => {
}, },
); );
}); });
describe("EC2 security-group rule requests", () => {
const fetchMock = vi.fn<typeof fetch>();
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");
});
});
+17 -4
View File
@@ -12,6 +12,18 @@ export type Ec2SecurityGroupRef = {
name?: string; 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 = { export type Ec2IpPermission = {
protocol: string; protocol: string;
fromPort: number | null; fromPort: number | null;
@@ -61,6 +73,7 @@ export type Ec2Instance = {
architecture?: string; architecture?: string;
platform?: string; platform?: string;
securityGroups: Ec2SecurityGroupRef[]; securityGroups: Ec2SecurityGroupRef[];
networkInterfaces: Ec2NetworkInterface[];
tags: Ec2Tag[]; tags: Ec2Tag[];
}; };
@@ -391,7 +404,7 @@ export async function authorizeEc2SecurityGroupIngress(
): Promise<void> { ): Promise<void> {
await apiClient.call( await apiClient.call(
apiEndpointKeys.aws.ec2.securityGroups.authorize, apiEndpointKeys.aws.ec2.securityGroups.authorize,
{ body: JSON.stringify(permission), headers: { "content-type": "application/json" } }, { body: permission },
{ groupId }, { groupId },
); );
} }
@@ -402,7 +415,7 @@ export async function revokeEc2SecurityGroupIngress(
): Promise<void> { ): Promise<void> {
await apiClient.call( await apiClient.call(
apiEndpointKeys.aws.ec2.securityGroups.revoke, apiEndpointKeys.aws.ec2.securityGroups.revoke,
{ body: JSON.stringify(permission), headers: { "content-type": "application/json" } }, { body: permission },
{ groupId }, { groupId },
); );
} }
@@ -413,7 +426,7 @@ export async function authorizeEc2SecurityGroupEgress(
): Promise<void> { ): Promise<void> {
await apiClient.call( await apiClient.call(
apiEndpointKeys.aws.ec2.securityGroups.authorizeEgress, apiEndpointKeys.aws.ec2.securityGroups.authorizeEgress,
{ body: JSON.stringify(permission), headers: { "content-type": "application/json" } }, { body: permission },
{ groupId }, { groupId },
); );
} }
@@ -424,7 +437,7 @@ export async function revokeEc2SecurityGroupEgress(
): Promise<void> { ): Promise<void> {
await apiClient.call( await apiClient.call(
apiEndpointKeys.aws.ec2.securityGroups.revokeEgress, apiEndpointKeys.aws.ec2.securityGroups.revokeEgress,
{ body: JSON.stringify(permission), headers: { "content-type": "application/json" } }, { body: permission },
{ groupId }, { groupId },
); );
} }
+63 -1
View File
@@ -108,15 +108,34 @@ export async function getServiceSchema(
return res.data; 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<LoadBalancingDependencies & {instances?: Array<{id: string; name: string; privateIp: string | null}>}> {
const res = await apiClient.call<LoadBalancingDependencies>(
apiEndpointKeys.clouds.dependencies,
requestOptions("aws", service, {signal}),
{cloud: "aws", service},
);
return res.data;
}
export async function listCloudResources( export async function listCloudResources(
cloud: CloudProvider, cloud: CloudProvider,
service: CloudServiceType, service: CloudServiceType,
search?: string, search?: string,
signal?: AbortSignal, signal?: AbortSignal,
state?: string,
): Promise<CloudResource[]> { ): Promise<CloudResource[]> {
const res = await apiClient.call<CloudResource[]>( const res = await apiClient.call<CloudResource[]>(
apiEndpointKeys.clouds.resources.list, apiEndpointKeys.clouds.resources.list,
requestOptions(cloud, service, { signal, params: { search } }), requestOptions(cloud, service, { signal, params: { search, state } }),
{ cloud, service }, { cloud, service },
); );
return res.data; return res.data;
@@ -905,6 +924,49 @@ export async function listCollectionItems(
return res.data; return res.data;
} }
export async function listFlatItems(
cloud: CloudProvider,
service: CloudServiceType,
resourceId: string,
cursor?: string,
signal?: AbortSignal,
): Promise<CollectionPage<ChildItem>> {
const res = await apiClient.call<CollectionPage<ChildItem>>(
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<void> {
await apiClient.call<void>(
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<string, unknown>,
): Promise<ChildItem> {
const res = await apiClient.call<ChildItem, Record<string, unknown>>(
apiEndpointKeys.clouds.flatItems.create,
requestOptions(cloud, service, {body}),
{cloud, service, id: resourceId},
);
return res.data;
}
export async function listSqlDatabases( export async function listSqlDatabases(
cloud: CloudProvider, cloud: CloudProvider,
serverId: string, serverId: string,
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
AlertTriangle, AlertTriangle,
@@ -583,6 +583,22 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
</span> </span>
</div> </div>
)} )}
{inst.networkInterfaces.length > 0 && (
<div className="meta-row" style={{ gridColumn: '1 / -1', alignItems: 'start' }}>
<span className="meta-label">Network interfaces</span>
<span className="meta-value" style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{inst.networkInterfaces.map((networkInterface) => (
<span key={networkInterface.networkInterfaceId} style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
<code>{networkInterface.networkInterfaceId}</code>
{networkInterface.deviceIndex !== undefined && <span>device {networkInterface.deviceIndex}</span>}
{networkInterface.status && <span>{networkInterface.status}</span>}
{networkInterface.privateIpAddress && <span>private {networkInterface.privateIpAddress}</span>}
{networkInterface.publicIpAddress && <span>public {networkInterface.publicIpAddress}</span>}
</span>
))}
</span>
</div>
)}
</div> </div>
)} )}
@@ -789,6 +805,23 @@ export function LaunchInstanceForm({
const vpcsQ = useQuery({ queryKey: ['ec2', 'vpcs'], queryFn: ({ signal }) => listEc2Vpcs(signal) }) const vpcsQ = useQuery({ queryKey: ['ec2', 'vpcs'], queryFn: ({ signal }) => listEc2Vpcs(signal) })
const subsQ = useQuery({ queryKey: ['ec2', 'subnets'], queryFn: ({ signal }) => listEc2Subnets(undefined, 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 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({ const createMut = useMutation({
mutationFn: (values: Record<string, unknown>) => createCloudResource(cloud, 'compute', values), mutationFn: (values: Record<string, unknown>) => createCloudResource(cloud, 'compute', values),
@@ -845,6 +878,13 @@ export function LaunchInstanceForm({
} }
function launch() { 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({ createMut.mutate({
name, name,
imageId, imageId,
@@ -908,8 +948,8 @@ export function LaunchInstanceForm({
return !quickStartImageIds.has(ami.imageId) && (!normalizedAmiSearch || searchable.includes(normalizedAmiSearch)) return !quickStartImageIds.has(ami.imageId) && (!normalizedAmiSearch || searchable.includes(normalizedAmiSearch))
}) })
const selectedAmi = amis.find((ami) => ami.imageId === imageId) 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 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 }> = [ const steps: Array<{ number: LaunchStep; label: string }> = [
{ number: 1, label: 'Name and AMI' }, { number: 1, label: 'Name and AMI' },
{ number: 2, label: 'Instance and key pair' }, { number: 2, label: 'Instance and key pair' },
@@ -1046,10 +1086,11 @@ export function LaunchInstanceForm({
<div className="sg-checklist"> <div className="sg-checklist">
{sgsQ.isLoading && <span className="muted">Loading…</span>} {sgsQ.isLoading && <span className="muted">Loading…</span>}
{selectedSubnet && <span className="muted">Showing groups in {selectedSubnet.vpcId} only.</span>}
{sgsQ.data?.length === 0 && !showCreateSg && ( {sgsQ.data?.length === 0 && !showCreateSg && (
<span className="muted">No security groups — create one above.</span> <span className="muted">No security groups — create one above.</span>
)} )}
{(sgsQ.data ?? []).map((sg: Ec2SecurityGroup) => ( {availableSecurityGroups.map((sg: Ec2SecurityGroup) => (
<label key={sg.groupId} className="sg-check-item"> <label key={sg.groupId} className="sg-check-item">
<input <input
type="checkbox" type="checkbox"
@@ -0,0 +1,67 @@
import {useState} from 'react';
import {Check, Copy} from 'lucide-react';
interface CopyValueButtonProps {
value: string;
label: string;
}
export function CopyValueButton({value, label}: CopyValueButtonProps) {
const [status, setStatus] = useState<'idle' | 'copied' | 'failed'>('idle');
async function copy() {
const copied = await copyText(value);
setStatus(copied ? 'copied' : 'failed');
window.setTimeout(() => setStatus('idle'), 2_000);
}
const message = status === 'copied'
? `${label} copied`
: status === 'failed'
? `Unable to copy ${label}`
: '';
return (
<>
<button
className="icon-btn copy-value-button"
type="button"
onClick={(event) => {
event.stopPropagation();
void copy();
}}
title={status === 'failed' ? `Unable to copy ${label}` : `Copy ${label}`}
aria-label={status === 'failed' ? `Unable to copy ${label}` : `Copy ${label}`}
>
{status === 'copied' ? <Check size={13} /> : <Copy size={13} />}
</button>
<span className="visually-hidden" role="status">{message}</span>
</>
);
}
async function copyText(value: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return true;
}
} catch {
// Some embedded or insecure contexts expose Clipboard API but reject writes.
}
const textarea = document.createElement('textarea');
try {
textarea.value = value;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.append(textarea);
textarea.select();
return document.execCommand('copy');
} catch {
return false;
} finally {
textarea.remove();
}
}
@@ -53,6 +53,10 @@ import { LambdaInvocationHistoryPanel } from "@/components/LambdaInvocationHisto
import { LambdaCreatePanel } from "@/components/LambdaCreatePanel"; import { LambdaCreatePanel } from "@/components/LambdaCreatePanel";
import { bucketDeleteWarning } from "@/lib/bucketDeletion"; import { bucketDeleteWarning } from "@/lib/bucketDeletion";
import { SnsTopicPanel } from "@/components/SnsTopicPanel"; import { SnsTopicPanel } from "@/components/SnsTopicPanel";
import { EcrImageBrowser } from "@/components/EcrImageBrowser";
import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
interface DynamicResourceViewProps { interface DynamicResourceViewProps {
cloud: CloudProvider; cloud: CloudProvider;
@@ -76,6 +80,7 @@ export function DynamicResourceView({
}: DynamicResourceViewProps) { }: DynamicResourceViewProps) {
const qc = useQueryClient(); const qc = useQueryClient();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [state, setState] = useState("");
const [databaseTab, setDatabaseTab] = useState<"instances" | "snapshots">("instances"); const [databaseTab, setDatabaseTab] = useState<"instances" | "snapshots">("instances");
const [selected, setSelected] = useState<CloudResource | undefined>(); const [selected, setSelected] = useState<CloudResource | undefined>();
const [selectedObject, setSelectedObject] = useState< const [selectedObject, setSelectedObject] = useState<
@@ -104,8 +109,8 @@ export function DynamicResourceView({
}; };
const resourcesKey = useMemo( const resourcesKey = useMemo(
() => ["cloud-resources", cloud, service, search], () => ["cloud-resources", cloud, service, search, state],
[cloud, service, search], [cloud, service, search, state],
); );
const schemaQuery = useQuery({ const schemaQuery = useQuery({
@@ -115,7 +120,7 @@ export function DynamicResourceView({
const resourcesQuery = useQuery({ const resourcesQuery = useQuery({
queryKey: resourcesKey, queryKey: resourcesKey,
queryFn: ({ signal }) => listCloudResources(cloud, service, search, signal), queryFn: ({ signal }) => listCloudResources(cloud, service, search, signal, state),
enabled: enabled:
schemaQuery.isSuccess && schemaQuery.isSuccess &&
serviceAvailability === "available" && serviceAvailability === "available" &&
@@ -433,6 +438,7 @@ export function DynamicResourceView({
setEditingResource(null); setEditingResource(null);
setUpdateError(null); setUpdateError(null);
setSearch(""); setSearch("");
setState("");
setDatabaseTab("instances"); setDatabaseTab("instances");
setIsDeleteMulti(false); setIsDeleteMulti(false);
setSelectedItems([]); setSelectedItems([]);
@@ -599,6 +605,18 @@ export function DynamicResourceView({
placeholder="Filter resources" placeholder="Filter resources"
/> />
</div> </div>
{schema.filters.find((filter) => filter.name === "state" && filter.type === "select") && (
<select
className="input"
aria-label="Filter by lifecycle state"
value={state}
onChange={(event) => setState(event.target.value)}
>
{schema.filters.find((filter) => filter.name === "state")?.options?.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
)}
{service === "email" && ( {service === "email" && (
clearConfirm ? ( clearConfirm ? (
<> <>
@@ -756,6 +774,13 @@ export function DynamicResourceView({
onCancel={() => setCreateOpen(false)} onCancel={() => setCreateOpen(false)}
onSubmit={(values) => createMut.mutate(values)} onSubmit={(values) => createMut.mutate(values)}
/> />
) : service === "loadbalancing" && cloud === "aws" ? (
<LoadBalancerPanel
isSubmitting={createMut.isPending}
submitError={createMut.error instanceof Error ? createMut.error.message : null}
onCancel={() => setCreateOpen(false)}
onSubmit={(values) => createMut.mutate(values)}
/>
) : ( ) : (
<DynamicFormRenderer <DynamicFormRenderer
key={`${cloud}:${service}:create`} key={`${cloud}:${service}:create`}
@@ -832,6 +857,15 @@ export function DynamicResourceView({
onSelectObject={setSelectedObject} onSelectObject={setSelectedObject}
/> />
)} )}
{service === "ecr" && cloud === "aws" && (
<EcrImageBrowser resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "targetgroups" && cloud === "aws" && (
<TargetGroupPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "loadbalancing" && cloud === "aws" && (
<LoadBalancerListenerPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "compute" && ( {service === "compute" && (
<ComputePanel <ComputePanel
cloud={cloud} cloud={cloud}
@@ -940,7 +974,15 @@ export function DynamicResourceView({
<X size={14} /> <X size={14} />
</button> </button>
</div> </div>
<DynamicFormRenderer {service === "loadbalancing" && cloud === "aws" ? (
<LoadBalancerPanel
resource={editingResource}
isSubmitting={updateMut.isPending}
submitError={updateError}
onCancel={() => setEditingResource(null)}
onSubmit={handleEditSubmit}
/>
) : <DynamicFormRenderer
key={editingResource.id} key={editingResource.id}
schema={schema} schema={schema}
fields={schema.updateFields} fields={schema.updateFields}
@@ -951,7 +993,7 @@ export function DynamicResourceView({
submitError={updateError} submitError={updateError}
onCancel={() => setEditingResource(null)} onCancel={() => setEditingResource(null)}
onSubmit={handleEditSubmit} onSubmit={handleEditSubmit}
/> />}
</div> </div>
</div> </div>
)} )}
@@ -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 <section className="table-panel"><div className="empty compact"><h3>Select a repository</h3><p>Choose an ECR repository to browse its images.</p></div></section>
}
const images = imagesQuery.data?.items ?? []
return (
<section className="table-panel" style={{marginTop: 16}}>
<div className="input-row resource-table-bar">
<div>
<p className="eyebrow">Repository images</p>
<span className="muted">{images.length} images in {resource.name}</span>
</div>
<button className="button" type="button" disabled={!runtimeReachable || imagesQuery.isFetching} onClick={() => imagesQuery.refetch()}>
<RefreshCw size={14}/>{imagesQuery.isFetching ? 'Loading' : 'Refresh'}
</button>
</div>
{imagesQuery.error instanceof Error && <p className="error-text compact-text">{imagesQuery.error.message}</p>}
{imagesQuery.isLoading && <div className="muted padded">Loading images</div>}
{imagesQuery.isSuccess && images.length === 0 && <div className="empty compact"><h3>No images</h3><p>Push an image to this repository to see it here.</p></div>}
{images.length > 0 && <div className="cosmos-items-table">
<table>
<thead><tr><th>Tags</th><th>Digest</th><th>Pushed</th><th>Size</th><th/></tr></thead>
<tbody>{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 <tr key={image.id}>
<td>{tags.length ? tags.join(', ') : <span className="muted">Untagged</span>}</td>
<td><code>{shortDigest(image.id)}</code></td>
<td>{image.timestamp ? new Date(image.timestamp).toLocaleString() : '-'}</td>
<td>{size}</td>
<td><button className="icon-btn danger" type="button" title="Delete image" aria-label={`Delete ${image.id}`} disabled={!runtimeReachable || deleteMutation.isPending} onClick={() => deleteMutation.mutate(image.id)}><Trash2 size={14}/></button></td>
</tr>
})}</tbody>
</table>
</div>}
</section>
)
}
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`
}
@@ -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(<QueryClientProvider client={client}><LoadBalancerListenerPanel runtimeReachable resource={{id: 'load-balancer-arn', name: 'web', cloud: 'aws', service: 'loadbalancing', type: 'load-balancer', region: null, createdAt: null, metadata: {}}}/></QueryClientProvider>)
await screen.findByText('web-targets (target-group-arn)')
await 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')
})
@@ -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 <section className="table-panel"><div className="empty compact"><h3>Select an Application Load Balancer</h3><p>Choose a load balancer to create and manage HTTP listeners.</p></div></section>
}
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 <section className="table-panel" style={{marginTop: 16}}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Listeners</p><span className="muted">{listeners.length} listeners on {resource.name}</span></div>
<button className="button" type="button" disabled={!runtimeReachable || listenersQuery.isFetching} onClick={() => { void Promise.all([listenersQuery.refetch(), targetGroupsQuery.refetch()]) }}><RefreshCw size={14}/>{listenersQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (targetGroupArn && validPort) createMutation.mutate() }}>
<label className="dynamic-field"><span>Protocol</span><input className="input" value="HTTP" disabled/></label>
<label className="dynamic-field"><span>Listener port</span><input className="input" type="number" min="1" max="65535" value={port} onChange={(event) => setPort(event.target.value)} disabled={pending}/></label>
<label className="dynamic-field dynamic-field--span"><span>Default target group</span><select className="input" value={targetGroupArn} onChange={(event) => setTargetGroupArn(event.target.value)} disabled={targetGroupsQuery.isLoading || pending}><option value="">Select a target group</option>{targetGroups.map((group) => <option key={group.id} value={group.id}>{group.name} ({group.id})</option>)}</select></label>
<div style={{alignSelf: 'end'}}><button className="button primary" type="submit" disabled={!runtimeReachable || !targetGroupArn || !validPort || pending}>{createMutation.isPending ? 'Creating' : 'Create listener'}</button></div>
{targetGroups.length === 0 && targetGroupsQuery.isSuccess && <p className="muted">No target groups are available in this account.</p>}
</form>
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
{listenersQuery.isLoading && <div className="muted padded">Loading listeners</div>}
{listenersQuery.isSuccess && listeners.length === 0 && <div className="empty compact"><h3>No listeners</h3><p>Create an HTTP listener that forwards requests to a target group.</p></div>}
{listeners.length > 0 && <div className="cosmos-items-table"><table><thead><tr><th>Protocol</th><th>Port</th><th>Default target group</th><th/></tr></thead><tbody>{listeners.map((listener) => <tr key={listener.id}><td>{stringValue(listener.body.protocol)}</td><td>{stringValue(listener.body.port)}</td><td><code>{stringValue(listener.body.targetGroupArn)}</code></td><td><button className="icon-btn danger" type="button" title="Delete listener" aria-label={`Delete listener ${stringValue(listener.body.port)}`} disabled={!runtimeReachable || pending} onClick={() => deleteMutation.mutate(listener.id)}><Trash2 size={14}/></button></td></tr>)}</tbody></table></div>}
</section>
}
function stringValue(value: unknown): string {
return typeof value === 'string' || typeof value === 'number' ? String(value) : ''
}
@@ -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(<QueryClientProvider client={new QueryClient()}><LoadBalancerPanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/></QueryClientProvider>)
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'})
})
@@ -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<string, unknown>) => 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<string[]>([])
const [securityGroupIds, setSecurityGroupIds] = useState<string[]>([])
const [error, setError] = useState<string | null>(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<HTMLFormElement>) {
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 <p className="muted">Loading VPC configuration...</p>
if (dependencies.isError) return <p className="form-error" role="alert">Unable to load VPC configuration.</p>
return <form className="dynamic-form" onSubmit={submit} noValidate>
<div className="dynamic-form-group">{resource ? 'Security groups' : 'Application Load Balancer'}</div>
{!resource && <label className="dynamic-field"><span>Load Balancer Name<em className="field-required">*</em></span><input className="input" value={name} onChange={(event) => setName(event.target.value)} /></label>}
{!resource && <label className="dynamic-field"><span>Scheme</span><select className="input" value={scheme} onChange={(event) => setScheme(event.target.value)}><option value="internet-facing">internet-facing</option><option value="internal">internal</option></select></label>}
<label className="dynamic-field"><span>VPC<em className="field-required">*</em></span><select className="input" value={vpcId} required onChange={(event) => selectVpc(event.target.value)} disabled={Boolean(resource)}><option value="">Select a VPC</option>{(dependencies.data?.vpcs ?? []).map((vpc) => <option key={vpc.id} value={vpc.id}>{vpc.id} ({vpc.cidrBlock})</option>)}</select></label>
{!resource && <fieldset className="dynamic-field dynamic-field--span"><legend>Subnets<em className="field-required">*</em></legend><small>Select at least two subnets in distinct Availability Zones.</small>{subnets.map((subnet) => <label key={subnet.id}><input type="checkbox" checked={subnetIds.includes(subnet.id)} onChange={() => setSubnetIds((current) => toggle(current, subnet.id))} /> {subnet.id} ({subnet.availabilityZone}, {subnet.cidrBlock})</label>)}</fieldset>}
<fieldset className="dynamic-field dynamic-field--span"><legend>Security Groups<em className="field-required">*</em></legend>{groups.map((group) => <label key={group.id}><input type="checkbox" checked={securityGroupIds.includes(group.id)} onChange={() => setSecurityGroupIds((current) => toggle(current, group.id))} /> {group.name || group.id} ({group.id})</label>)}</fieldset>
<div style={{gridColumn: '1 / -1', justifySelf: 'end', display: 'flex', gap: 8}}><button className="button" type="button" disabled={isSubmitting} onClick={onCancel}>Cancel</button><button className="button primary" type="submit" disabled={isSubmitting}>{isSubmitting ? (resource ? 'Saving' : 'Creating') : (resource ? 'Set Security Groups' : 'Create Load Balancer')}</button></div>
{(error || submitError) && <div className="form-error" role="alert">{error ?? submitError}</div>}
</form>
}
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') : []
}
@@ -229,8 +229,8 @@ function EditRuleTable({rules, onChange}: {rules: EditRule[]; onChange: (rules:
No rules. Click Add rule to add one. No rules. Click Add rule to add one.
</div> </div>
) : ( ) : (
<div style={{border: '1px solid var(--border)', borderRadius: 4, marginBottom: 8}}> <div className="sg-edit-rule-table-wrap">
<table style={{width: '100%', borderCollapse: 'collapse', fontSize: 12}}> <table className="sg-edit-rule-table">
<thead> <thead>
<tr style={{borderBottom: '1px solid var(--border)'}}> <tr style={{borderBottom: '1px solid var(--border)'}}>
<th style={thS}>Type</th> <th style={thS}>Type</th>
@@ -248,10 +248,10 @@ function EditRuleTable({rules, onChange}: {rules: EditRule[]; onChange: (rules:
const toErr = isCustom(r) && r.toPort !== '' && !isValidPort(r.toPort) const toErr = isCustom(r) && r.toPort !== '' && !isValidPort(r.toPort)
return ( return (
<tr key={r.rid} style={{borderBottom: idx < rules.length - 1 ? '1px solid var(--border-faint)' : 'none'}}> <tr key={r.rid} style={{borderBottom: idx < rules.length - 1 ? '1px solid var(--border-faint)' : 'none'}}>
<td style={tdS}> <td className="sg-edit-type-cell" style={tdS}>
<select <select
className="input" className="input"
style={{width: 130, fontSize: 12}} style={{fontSize: 12}}
value={pKey} value={pKey}
onChange={(e) => handlePreset(r.rid, e.target.value)} onChange={(e) => handlePreset(r.rid, e.target.value)}
> >
@@ -407,7 +407,7 @@ function EditSgRulesModal({sg, onClose}: {sg: Ec2SecurityGroup; onClose: () => v
return ( return (
<div className="modal-overlay" onClick={(e) => {if (e.target === e.currentTarget) onClose()}}> <div className="modal-overlay" onClick={(e) => {if (e.target === e.currentTarget) onClose()}}>
<div className="create-table-modal" style={{maxWidth: 680}}> <div className="create-table-modal sg-rules-edit-modal">
<div className="modal-header" style={{display: 'flex', alignItems: 'center', gap: 8}}> <div className="modal-header" style={{display: 'flex', alignItems: 'center', gap: 8}}>
<Shield size={14} style={{color: 'var(--accent)'}}/> <Shield size={14} style={{color: 'var(--accent)'}}/>
<span>Edit rules — {sg.groupName}</span> <span>Edit rules — {sg.groupName}</span>
@@ -5,6 +5,7 @@ import { LogsQueryPanel } from "@/components/LogsQueryPanel";
import type { CloudProvider } from "@/types/cloud"; import type { CloudProvider } from "@/types/cloud";
import type { CloudResource, StorageObject } from "@/types/resource"; import type { CloudResource, StorageObject } from "@/types/resource";
import {formatBytes} from "@/lib/format"; import {formatBytes} from "@/lib/format";
import {CopyValueButton} from "@/components/CopyValueButton";
interface ResourceInspectorProps { interface ResourceInspectorProps {
resource?: CloudResource; resource?: CloudResource;
@@ -82,6 +83,7 @@ export function ResourceInspector({
</div> </div>
<div className="inspector-grid"> <div className="inspector-grid">
<InspectorItem label="Cloud" value={resource.cloud} /> <InspectorItem label="Cloud" value={resource.cloud} />
<InspectorItem label="Resource ID" value={resource.id} copyable />
<InspectorItem label="Service" value={serviceName ?? resource.service} /> <InspectorItem label="Service" value={serviceName ?? resource.service} />
<InspectorItem label="Region" value={resource.region ?? "-"} /> <InspectorItem label="Region" value={resource.region ?? "-"} />
<InspectorItem label="Created At" value={resource.createdAt ?? "-"} /> <InspectorItem label="Created At" value={resource.createdAt ?? "-"} />
@@ -163,6 +165,7 @@ export function ResourceInspector({
<InspectorItem <InspectorItem
label="ARN" label="ARN"
value={getStringMetadata(resource.metadata.arn) ?? "-"} value={getStringMetadata(resource.metadata.arn) ?? "-"}
copyable
/> />
<InspectorItem <InspectorItem
label="Last Modified" label="Last Modified"
@@ -343,11 +346,14 @@ function DatabaseLifecycleSection({ status }: { status?: string | null }) {
} }
function InspectorItem({ label, value }: { label: string; value: string }) { function InspectorItem({ label, value, copyable = false }: { label: string; value: string; copyable?: boolean }) {
return ( return (
<div> <div>
<p className="metric-label">{label}</p> <p className="metric-label">{label}</p>
<p className="metric-value mono">{value}</p> <p className="metric-value mono copy-value">
{value}
{copyable && value !== "-" && <CopyValueButton value={value} label={label} />}
</p>
</div> </div>
); );
} }
@@ -370,13 +376,20 @@ function MetadataPanel({ metadata }: { metadata: Record<string, unknown> }) {
{rows.map(([key, value]) => ( {rows.map(([key, value]) => (
<div key={key}> <div key={key}>
<span>{humanizeKey(key)}</span> <span>{humanizeKey(key)}</span>
<span className="copy-value">
<code>{formatMetadataValue(value)}</code> <code>{formatMetadataValue(value)}</code>
{isCopyableMetadata(key, value) && <CopyValueButton value={formatMetadataValue(value)} label={humanizeKey(key)} />}
</span>
</div> </div>
))} ))}
</div> </div>
); );
} }
function isCopyableMetadata(key: string, value: unknown): value is string | number {
return (typeof value === "string" || typeof value === "number") && /id$|arn|ip|endpoint|url|uri|key|hash|token/i.test(key);
}
function formatMetadataValue(value: unknown): string { function formatMetadataValue(value: unknown): string {
return typeof value === "object" ? JSON.stringify(value) : String(value); return typeof value === "object" ? JSON.stringify(value) : String(value);
} }
@@ -5,6 +5,7 @@ import type {CloudResource} from '@/types/resource';
import type {ServiceSchema} from '@/types/schema'; import type {ServiceSchema} from '@/types/schema';
import {getPath} from '@/lib/resourcePath'; import {getPath} from '@/lib/resourcePath';
import {renderColumnValue} from '@/lib/columnFormat'; import {renderColumnValue} from '@/lib/columnFormat';
import {CopyValueButton} from '@/components/CopyValueButton';
interface ResourceTableProps { interface ResourceTableProps {
schema: ServiceSchema; schema: ServiceSchema;
@@ -102,7 +103,7 @@ export function ResourceTable({
)} )}
{schema.columns.map((column) => ( {schema.columns.map((column) => (
<td key={column.name} onClick={() => onSelect(resource)}> <td key={column.name} onClick={() => onSelect(resource)}>
{renderColumnValue(getPath(resource, column.path ?? column.name), column)} <CopyableColumnValue resource={resource} column={column} />
</td> </td>
))} ))}
{hasActions && ( {hasActions && (
@@ -188,3 +189,21 @@ export function ResourceTable({
</table> </table>
); );
} }
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 (
<span className="copy-value">
{renderColumnValue(value, column)}
<CopyValueButton value={value} label={column.label} />
</span>
);
}
@@ -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 <section className="table-panel"><div className="empty compact"><h3>Select a target group</h3><p>Choose a target group to register running instances and inspect target health.</p></div></section>
}
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 <section className="table-panel" style={{marginTop: 16}}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Target health</p><span className="muted">{targets.length} registered targets in {resource.name}</span></div>
<button className="button" type="button" disabled={!runtimeReachable || healthQuery.isFetching} onClick={refresh}><RefreshCw size={14}/>{healthQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (instanceId && validPort) registerMutation.mutate() }}>
<label className="dynamic-field"><span>Running instance</span><select className="input" value={instanceId} onChange={(event) => setInstanceId(event.target.value)} disabled={dependenciesQuery.isLoading || registerMutation.isPending}><option value="">Select an instance</option>{instances.map((instance) => <option key={instance.id} value={instance.id}>{instance.name} ({instance.id}{instance.privateIp ? `, ${instance.privateIp}` : ''})</option>)}</select></label>
<label className="dynamic-field"><span>Target port</span><input className="input" type="number" min="1" max="65535" value={port} onChange={(event) => setPort(event.target.value)} disabled={registerMutation.isPending}/></label>
<div style={{alignSelf: 'end'}}><button className="button primary" type="submit" disabled={!runtimeReachable || !instanceId || !validPort || registerMutation.isPending}>{registerMutation.isPending ? 'Registering' : 'Register target'}</button></div>
{instances.length === 0 && dependenciesQuery.isSuccess && <p className="muted">No running instances are available in this account.</p>}
</form>
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
{healthQuery.isLoading && <div className="muted padded">Loading target health</div>}
{healthQuery.isSuccess && targets.length === 0 && <div className="empty compact"><h3>No registered targets</h3><p>Register a running instance to see its ELB health state.</p></div>}
{targets.length > 0 && <div className="cosmos-items-table"><table><thead><tr><th>Instance</th><th>Port</th><th>State</th><th>Reason</th><th>Description</th><th/></tr></thead><tbody>{targets.map((target) => <tr key={target.id}><td><code>{stringValue(target.body.id)}</code></td><td>{stringValue(target.body.port)}</td><td>{stringValue(target.body.state) || '-'}</td><td>{stringValue(target.body.reason) || '-'}</td><td>{stringValue(target.body.description) || '-'}</td><td><button className="icon-btn danger" type="button" title="Deregister target" aria-label={`Deregister ${stringValue(target.body.id)}`} disabled={!runtimeReachable || deregisterMutation.isPending} onClick={() => deregisterMutation.mutate(target.id)}><Trash2 size={14}/></button></td></tr>)}</tbody></table></div>}
</section>
}
async function getTargetGroupInstances(signal?: AbortSignal): Promise<TargetInstance[]> {
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) : ''
}
@@ -33,6 +33,7 @@ import {
*/ */
const SERVICE_ICONS: Record<string, LucideIcon> = { const SERVICE_ICONS: Record<string, LucideIcon> = {
storage: HardDrive, storage: HardDrive,
ecr: Container,
database: Table2, database: Table2,
nosql: Database, nosql: Database,
k8s: Boxes, k8s: Boxes,
+83
View File
@@ -188,6 +188,18 @@ button {
border: 0; 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 { #main-content:focus {
outline: none; outline: none;
} }
@@ -4321,6 +4333,77 @@ button.console-service-card {
overflow-y: auto; 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 { .create-table-modal h3 {
margin: 0; margin: 0;
font-size: 15px; font-size: 15px;
+2
View File
@@ -26,6 +26,7 @@ export type KubernetesActionName =
| 'listFargateProfiles' | 'listFargateProfiles'
| 'createFargateProfile' | 'createFargateProfile'
| 'deleteFargateProfile' | 'deleteFargateProfile'
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon' export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
export interface CapabilitySchema<TAction extends string> { export interface CapabilitySchema<TAction extends string> {
@@ -83,6 +84,7 @@ export interface ServiceSchema {
objectActions?: Array<CapabilitySchema<ObjectActionName> | ObjectActionName> objectActions?: Array<CapabilitySchema<ObjectActionName> | ObjectActionName>
databaseActions?: Array<CapabilitySchema<DatabaseActionName> | DatabaseActionName> databaseActions?: Array<CapabilitySchema<DatabaseActionName> | DatabaseActionName>
kubernetesActions?: Array<CapabilitySchema<KubernetesActionName> | KubernetesActionName> kubernetesActions?: Array<CapabilitySchema<KubernetesActionName> | KubernetesActionName>
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
} }
filters: FieldSchema[] filters: FieldSchema[]
columns: TableColumnSchema[] columns: TableColumnSchema[]
+52 -32
View File
@@ -28,6 +28,9 @@ importers:
'@aws-sdk/client-ec2': '@aws-sdk/client-ec2':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1076.0 version: 3.1076.0
'@aws-sdk/client-ecr':
specifier: ^3.1076.0
version: 3.1076.0
'@aws-sdk/client-eks': '@aws-sdk/client-eks':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1076.0 version: 3.1076.0
@@ -256,6 +259,10 @@ packages:
resolution: {integrity: sha512-6i5gM3QTB6nLU1HjkQG0BIBXtrZ/+ifcv3jDf0dUxvmNSxETZH1i8OujAnBNq2iQ9Kt0xny88cs8rgV9rZeD0g==} resolution: {integrity: sha512-6i5gM3QTB6nLU1HjkQG0BIBXtrZ/+ifcv3jDf0dUxvmNSxETZH1i8OujAnBNq2iQ9Kt0xny88cs8rgV9rZeD0g==}
engines: {node: '>=20.0.0'} 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': '@aws-sdk/client-eks@3.1076.0':
resolution: {integrity: sha512-Hqr3hdwUgqWAQbdx6UDy81UBF+jdu3ws6RrykQ4YmBhe/XOKv57lt2lhkjgBuF9fiSw4UUl2XQteks9oxgm4XA==} resolution: {integrity: sha512-Hqr3hdwUgqWAQbdx6UDy81UBF+jdu3ws6RrykQ4YmBhe/XOKv57lt2lhkjgBuF9fiSw4UUl2XQteks9oxgm4XA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -2629,6 +2636,19 @@ snapshots:
'@smithy/types': 4.15.1 '@smithy/types': 4.15.1
tslib: 2.8.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': '@aws-sdk/client-eks@3.1076.0':
dependencies: dependencies:
'@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0
@@ -3079,20 +3099,20 @@ snapshots:
'@aws-sdk/credential-provider-login@3.972.76': '@aws-sdk/credential-provider-login@3.972.76':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.1
'@aws-sdk/nested-clients': 3.997.45 '@aws-sdk/nested-clients': 3.997.45
'@aws-sdk/types': 3.974.5 '@aws-sdk/types': 3.974.6
'@smithy/core': 3.33.3 '@smithy/core': 3.35.0
'@smithy/types': 4.17.2 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.77': '@aws-sdk/credential-provider-login@3.972.77':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.1
'@aws-sdk/nested-clients': 3.997.45 '@aws-sdk/nested-clients': 3.997.45
'@aws-sdk/types': 3.974.5 '@aws-sdk/types': 3.974.6
'@smithy/core': 3.33.3 '@smithy/core': 3.35.0
'@smithy/types': 4.17.2 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.78': '@aws-sdk/credential-provider-login@3.972.78':
@@ -3463,24 +3483,24 @@ snapshots:
'@aws-sdk/nested-clients@3.997.43': '@aws-sdk/nested-clients@3.997.43':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.1
'@aws-sdk/signature-v4-multi-region': 3.996.45 '@aws-sdk/signature-v4-multi-region': 3.996.45
'@aws-sdk/types': 3.974.5 '@aws-sdk/types': 3.974.6
'@smithy/core': 3.33.3 '@smithy/core': 3.35.0
'@smithy/fetch-http-handler': 5.7.2 '@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.11.3 '@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.17.2 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/nested-clients@3.997.44': '@aws-sdk/nested-clients@3.997.44':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.1
'@aws-sdk/signature-v4-multi-region': 3.996.46 '@aws-sdk/signature-v4-multi-region': 3.996.46
'@aws-sdk/types': 3.974.5 '@aws-sdk/types': 3.974.6
'@smithy/core': 3.33.3 '@smithy/core': 3.35.0
'@smithy/fetch-http-handler': 5.7.2 '@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.11.3 '@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.17.2 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/nested-clients@3.997.45': '@aws-sdk/nested-clients@3.997.45':
@@ -3514,16 +3534,16 @@ snapshots:
'@aws-sdk/signature-v4-multi-region@3.996.42': '@aws-sdk/signature-v4-multi-region@3.996.42':
dependencies: dependencies:
'@aws-sdk/types': 3.974.5 '@aws-sdk/types': 3.974.6
'@smithy/signature-v4': 5.7.0 '@smithy/signature-v4': 5.7.0
'@smithy/types': 4.17.2 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.45': '@aws-sdk/signature-v4-multi-region@3.996.45':
dependencies: dependencies:
'@aws-sdk/types': 3.974.5 '@aws-sdk/types': 3.974.6
'@smithy/signature-v4': 5.7.0 '@smithy/signature-v4': 5.7.0
'@smithy/types': 4.17.2 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.46': '@aws-sdk/signature-v4-multi-region@3.996.46':
@@ -3560,20 +3580,20 @@ snapshots:
'@aws-sdk/token-providers@3.1111.0': '@aws-sdk/token-providers@3.1111.0':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.1
'@aws-sdk/nested-clients': 3.997.45 '@aws-sdk/nested-clients': 3.997.45
'@aws-sdk/types': 3.974.5 '@aws-sdk/types': 3.974.6
'@smithy/core': 3.33.3 '@smithy/core': 3.35.0
'@smithy/types': 4.17.2 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/token-providers@3.1116.0': '@aws-sdk/token-providers@3.1116.0':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.1
'@aws-sdk/nested-clients': 3.997.45 '@aws-sdk/nested-clients': 3.997.45
'@aws-sdk/types': 3.974.5 '@aws-sdk/types': 3.974.6
'@smithy/core': 3.33.3 '@smithy/core': 3.35.0
'@smithy/types': 4.17.2 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/token-providers@3.1129.0': '@aws-sdk/token-providers@3.1129.0':