feat: fully functioning ALB
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
"@aws-sdk/client-cloudformation": "^3.1111.0",
|
||||
"@aws-sdk/client-cloudwatch-logs": "^3.1096.0",
|
||||
"@aws-sdk/client-dynamodb": "^3.1088.0",
|
||||
"@aws-sdk/client-ecr": "^3.1076.0",
|
||||
"@aws-sdk/client-ec2": "^3.1076.0",
|
||||
"@aws-sdk/client-eks": "^3.1076.0",
|
||||
"@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0",
|
||||
|
||||
@@ -18,6 +18,7 @@ const baseInstance: Ec2Instance = {
|
||||
architecture: 'x86_64',
|
||||
platform: undefined,
|
||||
securityGroups: [{id: 'sg-abc', name: 'default'}],
|
||||
networkInterfaces: [],
|
||||
tags: [{key: 'Name', value: 'web-server-1'}],
|
||||
}
|
||||
|
||||
@@ -25,14 +26,12 @@ function fakeService(overrides: Partial<{
|
||||
listInstances: () => Promise<Ec2Instance[]>
|
||||
describeInstance: (id: string) => Promise<Ec2Instance>
|
||||
terminateInstance: (id: string) => Promise<void>
|
||||
listAmis: () => Promise<[]>
|
||||
runInstance: (input: RunInstanceInput) => Promise<Ec2Instance>
|
||||
}> = {}) {
|
||||
return {
|
||||
listInstances: async () => [baseInstance],
|
||||
describeInstance: async (_id: string) => baseInstance,
|
||||
terminateInstance: async (_id: string) => {},
|
||||
listAmis: async () => [] as [],
|
||||
runInstance: async (_input: RunInstanceInput) => baseInstance,
|
||||
...overrides,
|
||||
}
|
||||
@@ -80,6 +79,20 @@ describe('AwsComputeAdapter', () => {
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('list filters instances by their EC2 lifecycle state', async () => {
|
||||
const adapter = new AwsComputeAdapter(fakeService({
|
||||
listInstances: async () => [
|
||||
baseInstance,
|
||||
{...baseInstance, instanceId: 'i-stopped', state: 'stopped'},
|
||||
],
|
||||
}))
|
||||
|
||||
const result = await adapter.list({state: 'stopped'})
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({id: 'i-stopped', status: 'stopped', type: 'instance'})
|
||||
})
|
||||
|
||||
test('get returns null when instance has 404 http status', async () => {
|
||||
const err = Object.assign(new Error('Not found'), {$metadata: {httpStatusCode: 404}})
|
||||
const adapter = new AwsComputeAdapter(fakeService({
|
||||
|
||||
@@ -7,13 +7,12 @@ import type {
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
} from '../cloud-spi/types'
|
||||
import {ec2Service, type Ec2Image, type Ec2Instance, type RunInstanceInput} from '../services/ec2'
|
||||
import {ec2Service, type Ec2Instance, type RunInstanceInput} from '../services/ec2'
|
||||
|
||||
type Ec2ServiceShape = {
|
||||
listInstances(): Promise<Ec2Instance[]>
|
||||
describeInstance(instanceId: string): Promise<Ec2Instance>
|
||||
terminateInstance(instanceId: string): Promise<void>
|
||||
listAmis(): Promise<Ec2Image[]>
|
||||
runInstance(input: RunInstanceInput): Promise<Ec2Instance>
|
||||
}
|
||||
|
||||
@@ -28,15 +27,10 @@ export class AwsComputeAdapter implements CloudServiceAdapter {
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const [instances, images] = await Promise.all([
|
||||
this.service_.listInstances(),
|
||||
this.service_.listAmis(),
|
||||
])
|
||||
const resources = [
|
||||
...instances.map(instanceToResource),
|
||||
...images.map(imageToResource),
|
||||
]
|
||||
return filterBySearch(resources, query.search)
|
||||
const resources = (await this.service_.listInstances()).map(instanceToResource)
|
||||
return filterBySearch(resources, query.search).filter((resource) =>
|
||||
!query.state || resource.status?.toLowerCase() === query.state.toLowerCase(),
|
||||
)
|
||||
}
|
||||
|
||||
async get(id: string): Promise<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[] {
|
||||
const normalized = search?.trim().toLowerCase()
|
||||
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 {
|
||||
CreateListenerCommand,
|
||||
CreateLoadBalancerCommand,
|
||||
DeleteListenerCommand,
|
||||
DeleteLoadBalancerCommand,
|
||||
DescribeListenersCommand,
|
||||
DescribeLoadBalancersCommand,
|
||||
SetSecurityGroupsCommand,
|
||||
type ElasticLoadBalancingV2Client,
|
||||
} from '@aws-sdk/client-elastic-load-balancing-v2'
|
||||
import {DescribeSecurityGroupsCommand, DescribeSubnetsCommand, DescribeVpcsCommand, type EC2Client} from '@aws-sdk/client-ec2'
|
||||
import {AwsLoadBalancingAdapter} from './AwsLoadBalancingAdapter'
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
@@ -48,11 +53,26 @@ function runtimeStub() {
|
||||
})
|
||||
}
|
||||
|
||||
const validValues = {name: 'web', subnets: 'subnet-default-b, subnet-default-c'}
|
||||
function stubEc2() {
|
||||
return {
|
||||
async send(command: object) {
|
||||
if (command instanceof DescribeSubnetsCommand) return {Subnets: [
|
||||
{SubnetId: 'subnet-default-b', VpcId: 'vpc-default', AvailabilityZone: 'us-east-1b', CidrBlock: '10.0.1.0/24'},
|
||||
{SubnetId: 'subnet-default-c', VpcId: 'vpc-default', AvailabilityZone: 'us-east-1c', CidrBlock: '10.0.2.0/24'},
|
||||
]}
|
||||
if (command instanceof DescribeVpcsCommand) return {Vpcs: [{VpcId: 'vpc-default', CidrBlock: '10.0.0.0/16'}]}
|
||||
if (command instanceof DescribeSecurityGroupsCommand) return {SecurityGroups: [{GroupId: 'sg-1', VpcId: 'vpc-default', GroupName: 'web'}]}
|
||||
return {}
|
||||
},
|
||||
} as unknown as EC2Client
|
||||
}
|
||||
|
||||
const validValues = {name: 'web', subnets: 'subnet-default-b, subnet-default-c', securityGroups: 'sg-1'}
|
||||
const LISTENER_ARN = 'arn:aws:elasticloadbalancing:us-east-1:000000000000:listener/app/web/77aa388f69144785/aabbccddeeff0011'
|
||||
|
||||
describe('AwsLoadBalancingAdapter', () => {
|
||||
test('identifies itself as the AWS load balancing adapter', () => {
|
||||
const adapter = new AwsLoadBalancingAdapter(runtimeStub().client)
|
||||
const adapter = new AwsLoadBalancingAdapter(runtimeStub().client, stubEc2())
|
||||
|
||||
expect(adapter.cloud).toBe('aws')
|
||||
expect(adapter.service).toBe('loadbalancing')
|
||||
@@ -61,7 +81,7 @@ describe('AwsLoadBalancingAdapter', () => {
|
||||
|
||||
test('lists load balancers and reads the nested state code', async () => {
|
||||
const {client, sent} = runtimeStub()
|
||||
const [resource] = await new AwsLoadBalancingAdapter(client).list()
|
||||
const [resource] = await new AwsLoadBalancingAdapter(client, stubEc2()).list()
|
||||
|
||||
expect(sent[0]).toBeInstanceOf(DescribeLoadBalancersCommand)
|
||||
expect(resource).toMatchObject({
|
||||
@@ -96,7 +116,7 @@ describe('AwsLoadBalancingAdapter', () => {
|
||||
return {LoadBalancers: [{...balancer, LoadBalancerName: 'api', LoadBalancerArn: `${ARN}-2`}]}
|
||||
})
|
||||
|
||||
const resources = await new AwsLoadBalancingAdapter(client).list()
|
||||
const resources = await new AwsLoadBalancingAdapter(client, stubEc2()).list()
|
||||
|
||||
expect(resources.map((r) => r.name)).toEqual(['web', 'api'])
|
||||
expect((sent[1] as DescribeLoadBalancersCommand).input.Marker).toBe('page-2')
|
||||
@@ -112,12 +132,12 @@ describe('AwsLoadBalancingAdapter', () => {
|
||||
return {LoadBalancers: [balancer], NextMarker: ''}
|
||||
})
|
||||
|
||||
await expect(new AwsLoadBalancingAdapter(client).list()).resolves.toHaveLength(1)
|
||||
await expect(new AwsLoadBalancingAdapter(client, stubEc2()).list()).resolves.toHaveLength(1)
|
||||
expect(call).toBe(1)
|
||||
})
|
||||
|
||||
test('filters by name and ARN', async () => {
|
||||
const adapter = new AwsLoadBalancingAdapter(runtimeStub().client)
|
||||
const adapter = new AwsLoadBalancingAdapter(runtimeStub().client, stubEc2())
|
||||
|
||||
await expect(adapter.list({search: 'we'})).resolves.toHaveLength(1)
|
||||
await expect(adapter.list({search: 'loadbalancer/app'})).resolves.toHaveLength(1)
|
||||
@@ -126,7 +146,7 @@ describe('AwsLoadBalancingAdapter', () => {
|
||||
|
||||
test('inspects a load balancer by ARN', async () => {
|
||||
const {client, sent} = runtimeStub()
|
||||
const resource = await new AwsLoadBalancingAdapter(client).get(ARN)
|
||||
const resource = await new AwsLoadBalancingAdapter(client, stubEc2()).get(ARN)
|
||||
|
||||
expect((sent[0] as DescribeLoadBalancersCommand).input.LoadBalancerArns).toEqual([ARN])
|
||||
expect(resource?.id).toBe(ARN)
|
||||
@@ -141,7 +161,7 @@ describe('AwsLoadBalancingAdapter', () => {
|
||||
$metadata: {httpStatusCode: 400},
|
||||
})
|
||||
})
|
||||
await expect(new AwsLoadBalancingAdapter(client).get(ARN)).resolves.toBeNull()
|
||||
await expect(new AwsLoadBalancingAdapter(client, stubEc2()).get(ARN)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('also returns null for the SDK-modelled exception name', async () => {
|
||||
@@ -154,12 +174,12 @@ describe('AwsLoadBalancingAdapter', () => {
|
||||
$metadata: {httpStatusCode: 400},
|
||||
})
|
||||
})
|
||||
await expect(new AwsLoadBalancingAdapter(client).get(ARN)).resolves.toBeNull()
|
||||
await expect(new AwsLoadBalancingAdapter(client, stubEc2()).get(ARN)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('returns null when the runtime answers with no load balancers', async () => {
|
||||
const {client} = stubElb(() => ({LoadBalancers: []}))
|
||||
await expect(new AwsLoadBalancingAdapter(client).get(ARN)).resolves.toBeNull()
|
||||
await expect(new AwsLoadBalancingAdapter(client, stubEc2()).get(ARN)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('rethrows a failure that is not a missing load balancer', async () => {
|
||||
@@ -169,17 +189,18 @@ describe('AwsLoadBalancingAdapter', () => {
|
||||
$metadata: {httpStatusCode: 403},
|
||||
})
|
||||
})
|
||||
await expect(new AwsLoadBalancingAdapter(client).get(ARN)).rejects.toThrow('AccessDenied')
|
||||
await expect(new AwsLoadBalancingAdapter(client, stubEc2()).get(ARN)).rejects.toThrow('AccessDenied')
|
||||
})
|
||||
|
||||
test('creates a load balancer, splitting the comma separated subnets', async () => {
|
||||
const {client, sent} = runtimeStub()
|
||||
const resource = await new AwsLoadBalancingAdapter(client).create({values: validValues})
|
||||
const resource = await new AwsLoadBalancingAdapter(client, stubEc2()).create({values: validValues})
|
||||
|
||||
const command = sent[0] as CreateLoadBalancerCommand
|
||||
expect(command).toBeInstanceOf(CreateLoadBalancerCommand)
|
||||
expect(command.input.Name).toBe('web')
|
||||
expect(command.input.Subnets).toEqual(['subnet-default-b', 'subnet-default-c'])
|
||||
expect(command.input.SecurityGroups).toEqual(['sg-1'])
|
||||
expect(command.input.Type).toBe('application')
|
||||
expect(command.input.Scheme).toBe('internet-facing')
|
||||
expect(resource.id).toBe(ARN)
|
||||
@@ -188,7 +209,7 @@ describe('AwsLoadBalancingAdapter', () => {
|
||||
test('requires at least two subnet ids', async () => {
|
||||
// Only the count is enforced. The AZ requirement is ELB's, and checking it
|
||||
// would mean calling EC2 from this adapter, so the message must not claim it.
|
||||
const adapter = new AwsLoadBalancingAdapter(runtimeStub().client)
|
||||
const adapter = new AwsLoadBalancingAdapter(runtimeStub().client, stubEc2())
|
||||
|
||||
await expect(adapter.create({values: {...validValues, subnets: 'subnet-only'}})).rejects.toThrow(
|
||||
new ValidationError('subnets must list at least two subnet ids'),
|
||||
@@ -196,7 +217,7 @@ describe('AwsLoadBalancingAdapter', () => {
|
||||
})
|
||||
|
||||
test('requires the fields the schema marks required', async () => {
|
||||
const adapter = new AwsLoadBalancingAdapter(runtimeStub().client)
|
||||
const adapter = new AwsLoadBalancingAdapter(runtimeStub().client, stubEc2())
|
||||
|
||||
await expect(adapter.create({values: {}})).rejects.toThrow(new ValidationError('name is required'))
|
||||
await expect(adapter.create({values: {name: 'web'}})).rejects.toThrow(
|
||||
@@ -205,19 +226,55 @@ describe('AwsLoadBalancingAdapter', () => {
|
||||
})
|
||||
|
||||
test('rejects values the runtime would refuse', async () => {
|
||||
const adapter = new AwsLoadBalancingAdapter(runtimeStub().client)
|
||||
const adapter = new AwsLoadBalancingAdapter(runtimeStub().client, stubEc2())
|
||||
|
||||
for (const name of ['-web', 'web-', 'has spaces', 'a'.repeat(33)]) {
|
||||
await expect(adapter.create({values: {...validValues, name}}), name).rejects.toThrow(ValidationError)
|
||||
}
|
||||
await expect(adapter.create({values: {...validValues, type: 'gateway'}})).rejects.toThrow(ValidationError)
|
||||
await expect(adapter.create({values: {...validValues, scheme: 'sideways'}})).rejects.toThrow(ValidationError)
|
||||
})
|
||||
|
||||
test('deletes a load balancer by ARN', async () => {
|
||||
const {client, sent} = runtimeStub()
|
||||
await new AwsLoadBalancingAdapter(client).delete(ARN)
|
||||
await new AwsLoadBalancingAdapter(client, stubEc2()).delete(ARN)
|
||||
|
||||
expect((sent[0] as DeleteLoadBalancerCommand).input.LoadBalancerArn).toBe(ARN)
|
||||
})
|
||||
|
||||
test('returns VPC dependencies from the account-scoped EC2 client', async () => {
|
||||
const dependencies = await new AwsLoadBalancingAdapter(runtimeStub().client, stubEc2()).dependencies()
|
||||
|
||||
expect(dependencies.vpcs).toEqual([{id: 'vpc-default', cidrBlock: '10.0.0.0/16'}])
|
||||
expect(dependencies.subnets).toHaveLength(2)
|
||||
expect(dependencies.securityGroups).toEqual([{id: 'sg-1', vpcId: 'vpc-default', name: 'web', description: ''}])
|
||||
})
|
||||
|
||||
test('sets security groups and returns the refreshed load balancer', async () => {
|
||||
const {client, sent} = runtimeStub()
|
||||
await new AwsLoadBalancingAdapter(client, stubEc2()).update(ARN, {values: {securityGroups: 'sg-1'}})
|
||||
|
||||
expect(sent[0]).toBeInstanceOf(SetSecurityGroupsCommand)
|
||||
expect((sent[0] as SetSecurityGroupsCommand).input.SecurityGroups).toEqual(['sg-1'])
|
||||
})
|
||||
|
||||
test('manages listeners through the generic flat-item extension', async () => {
|
||||
const {client, sent} = stubElb((command) => {
|
||||
if (command instanceof DescribeListenersCommand) {
|
||||
return {Listeners: [{ListenerArn: LISTENER_ARN, Protocol: 'HTTP', Port: 80, DefaultActions: [{Type: 'forward', TargetGroupArn: 'target-group-arn'}]}]}
|
||||
}
|
||||
if (command instanceof CreateListenerCommand) {
|
||||
return {Listeners: [{ListenerArn: LISTENER_ARN, Protocol: 'HTTP', Port: 8080, DefaultActions: [{Type: 'forward', TargetGroupArn: 'target-group-arn'}]}]}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
const items = new AwsLoadBalancingAdapter(client, stubEc2()).items
|
||||
|
||||
await expect(items.listItems(ARN)).resolves.toMatchObject({items: [{id: LISTENER_ARN, body: {protocol: 'HTTP', port: 80, targetGroupArn: 'target-group-arn'}}]})
|
||||
await expect(items.putItem!(ARN, {port: '8080', targetGroupArn: 'target-group-arn'})).resolves.toMatchObject({id: LISTENER_ARN})
|
||||
await items.deleteItem!(ARN, LISTENER_ARN)
|
||||
|
||||
expect((sent[0] as DescribeListenersCommand).input.LoadBalancerArn).toBe(ARN)
|
||||
expect((sent[1] as CreateListenerCommand).input).toMatchObject({LoadBalancerArn: ARN, Protocol: 'HTTP', Port: 8080, DefaultActions: [{Type: 'forward', TargetGroupArn: 'target-group-arn'}]})
|
||||
expect((sent[2] as DeleteListenerCommand).input.ListenerArn).toBe(LISTENER_ARN)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import {
|
||||
CreateListenerCommand,
|
||||
CreateLoadBalancerCommand,
|
||||
DeleteListenerCommand,
|
||||
DeleteLoadBalancerCommand,
|
||||
DescribeListenersCommand,
|
||||
DescribeLoadBalancersCommand,
|
||||
SetSecurityGroupsCommand,
|
||||
type ElasticLoadBalancingV2Client,
|
||||
type Listener,
|
||||
type LoadBalancer,
|
||||
} from '@aws-sdk/client-elastic-load-balancing-v2'
|
||||
import {DescribeSecurityGroupsCommand, DescribeSubnetsCommand, DescribeVpcsCommand, type EC2Client} from '@aws-sdk/client-ec2'
|
||||
import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections'
|
||||
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import {ELB_NAME_PATTERN, ELB_SCHEMES, ELB_TYPES, awsLoadBalancingSchema} from '../cloud-spi/loadBalancingSchema'
|
||||
import {ELB_NAME_PATTERN, ELB_SCHEMES, awsLoadBalancingSchema} from '../cloud-spi/loadBalancingSchema'
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
@@ -39,8 +46,11 @@ import type {
|
||||
export class AwsLoadBalancingAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' as const
|
||||
readonly service = 'loadbalancing' as const
|
||||
readonly items: ItemStoreAdapter
|
||||
|
||||
constructor(private readonly elb: ElasticLoadBalancingV2Client) {}
|
||||
constructor(private readonly elb: ElasticLoadBalancingV2Client, private readonly ec2: EC2Client) {
|
||||
this.items = new ListenerStore(elb)
|
||||
}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return awsLoadBalancingSchema()
|
||||
@@ -78,32 +88,93 @@ export class AwsLoadBalancingAdapter implements CloudServiceAdapter {
|
||||
)
|
||||
}
|
||||
|
||||
// Only the count is enforced. Whether the subnets sit in different
|
||||
// availability zones is ELB's rule, and checking it here would mean calling
|
||||
// EC2 DescribeSubnets from the load balancing adapter — real coupling for a
|
||||
// validation nicety. So the message claims only what this check does; the
|
||||
// schema field carries the AZ requirement as guidance.
|
||||
const subnets = splitIds(requiredString(input.values.subnets, 'subnets'))
|
||||
if (subnets.length < 2) {
|
||||
throw new ValidationError('subnets must list at least two subnet ids')
|
||||
}
|
||||
|
||||
const type = optionalOneOf(input.values.type, ELB_TYPES, 'type') ?? 'application'
|
||||
const securityGroups = splitIds(requiredString(input.values.securityGroups, 'securityGroups'))
|
||||
await this.validateSubnets(subnets)
|
||||
if (securityGroups.length === 0) throw new ValidationError('securityGroups must list at least one security group id')
|
||||
const scheme = optionalOneOf(input.values.scheme, ELB_SCHEMES, 'scheme') ?? 'internet-facing'
|
||||
|
||||
const res = await this.elb.send(
|
||||
new CreateLoadBalancerCommand({Name: name, Subnets: subnets, Type: type, Scheme: scheme}),
|
||||
new CreateLoadBalancerCommand({Name: name, Subnets: subnets, SecurityGroups: securityGroups, Type: 'application', Scheme: scheme}),
|
||||
)
|
||||
const created = res.LoadBalancers?.[0]
|
||||
if (!created) throw new RuntimeError(`ELB did not return the created load balancer for ${name}`)
|
||||
return toResource(created)
|
||||
}
|
||||
|
||||
async dependencies(): Promise<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> {
|
||||
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 {
|
||||
const arn = balancer.LoadBalancerArn ?? ''
|
||||
const zones = balancer.AvailabilityZones ?? []
|
||||
@@ -134,6 +205,24 @@ function toResource(balancer: LoadBalancer): CloudResource {
|
||||
}
|
||||
}
|
||||
|
||||
function toListenerItem(listener: Listener): ChildItem[] {
|
||||
const arn = listener.ListenerArn
|
||||
if (!arn) return []
|
||||
const action = listener.DefaultActions?.find((candidate) => candidate.Type === 'forward')
|
||||
return [{
|
||||
id: arn,
|
||||
collectionId: null,
|
||||
timestamp: null,
|
||||
body: {
|
||||
arn,
|
||||
protocol: listener.Protocol ?? null,
|
||||
port: listener.Port ?? null,
|
||||
targetGroupArn: action?.TargetGroupArn ?? null,
|
||||
},
|
||||
metadata: {},
|
||||
}]
|
||||
}
|
||||
|
||||
/** `arn:aws:elasticloadbalancing:us-east-1:...` — region is field 4. */
|
||||
function regionFromArn(arn: string): string | null {
|
||||
return arn.split(':')[3] || null
|
||||
@@ -176,6 +265,15 @@ function requiredString(value: unknown, field: string): string {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function requiredPort(value: unknown): number {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') throw new ValidationError('port is required')
|
||||
const port = typeof value === 'number' ? value : Number(value.trim())
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new ValidationError('port must be an integer from 1 to 65535')
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
function optionalOneOf<T extends readonly string[]>(value: unknown, allowed: T, field: string): T[number] | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`)
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { APIGatewayClient } from "@aws-sdk/client-api-gateway";
|
||||
import { CloudFormationClient } from "@aws-sdk/client-cloudformation";
|
||||
import { AppConfigClient } from "@aws-sdk/client-appconfig";
|
||||
import { SNSClient } from "@aws-sdk/client-sns";
|
||||
import { ECRClient } from "@aws-sdk/client-ecr";
|
||||
|
||||
const endpoint = process.env.FLOCI_ENDPOINT;
|
||||
const region = process.env.AWS_REGION || "us-east-1";
|
||||
@@ -65,6 +66,7 @@ export type AwsClients = {
|
||||
cloudformation: CloudFormationClient;
|
||||
appConfig: AppConfigClient;
|
||||
sns: SNSClient;
|
||||
ecr: ECRClient;
|
||||
};
|
||||
|
||||
export type AwsClientName = keyof AwsClients;
|
||||
@@ -97,6 +99,7 @@ function buildClients(accountId: string): AwsClients {
|
||||
cloudformation: new CloudFormationClient(base),
|
||||
appConfig: new AppConfigClient(base),
|
||||
sns: new SNSClient(base),
|
||||
ecr: new ECRClient(base),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,3 +137,4 @@ export const iam = awsClients.iam;
|
||||
export const dynamodb = awsClients.dynamodb;
|
||||
export const apiGateway = awsClients.apiGateway;
|
||||
export const cloudformation = awsClients.cloudformation;
|
||||
export const ecr = awsClients.ecr;
|
||||
|
||||
@@ -12,6 +12,25 @@ const computeFilters: FieldSchema[] = [
|
||||
{name: 'search', label: 'Search', type: 'text', required: false},
|
||||
]
|
||||
|
||||
const awsComputeFilters: FieldSchema[] = [
|
||||
...computeFilters,
|
||||
{
|
||||
name: 'state',
|
||||
label: 'Lifecycle state',
|
||||
type: 'select',
|
||||
required: false,
|
||||
options: [
|
||||
{label: 'All states', value: ''},
|
||||
{label: 'Running', value: 'running'},
|
||||
{label: 'Stopped', value: 'stopped'},
|
||||
{label: 'Pending', value: 'pending'},
|
||||
{label: 'Stopping', value: 'stopping'},
|
||||
{label: 'Shutting down', value: 'shutting-down'},
|
||||
{label: 'Terminated', value: 'terminated'},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const computeFields: FieldSchema[] = [
|
||||
// ── Required ──────────────────────────────────────────────────────
|
||||
{
|
||||
@@ -234,7 +253,7 @@ export function awsComputeSchema(): ServiceSchema {
|
||||
displayName: 'Compute',
|
||||
fields: computeFields,
|
||||
actions: ['list', 'inspect', 'create', 'delete'],
|
||||
filters: computeFilters,
|
||||
filters: awsComputeFilters,
|
||||
columns: computeColumns,
|
||||
capabilities: {
|
||||
resourceActions: [
|
||||
|
||||
@@ -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}]
|
||||
|
||||
export const ELB_TYPES = ['application', 'network'] as const
|
||||
export const ELB_SCHEMES = ['internet-facing', 'internal'] as const
|
||||
|
||||
/** ELBv2 names: up to 32 chars, alphanumerics and hyphens, no leading/trailing hyphen. */
|
||||
@@ -48,14 +47,6 @@ export function awsLoadBalancingSchema(): ServiceSchema {
|
||||
description:
|
||||
'Two or more subnet ids, comma separated — e.g. subnet-aaa, subnet-bbb. ELB requires them to be in different availability zones.',
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
label: 'Type',
|
||||
type: 'select',
|
||||
required: false,
|
||||
description: 'Defaults to application.',
|
||||
options: ELB_TYPES.map((value) => ({label: value, value})),
|
||||
},
|
||||
{
|
||||
name: 'scheme',
|
||||
label: 'Scheme',
|
||||
@@ -65,7 +56,10 @@ export function awsLoadBalancingSchema(): ServiceSchema {
|
||||
options: ELB_SCHEMES.map((value) => ({label: value, value})),
|
||||
},
|
||||
],
|
||||
actions: ['list', 'create', 'delete', 'inspect'],
|
||||
actions: ['list', 'create', 'update', 'delete', 'inspect'],
|
||||
updateFields: [
|
||||
{name: 'securityGroups', label: 'Security Groups', type: 'text', required: true, valuePath: 'metadata.securityGroups'},
|
||||
],
|
||||
capabilities: {
|
||||
resourceActions: [
|
||||
{name: 'list', label: 'List load balancers', enabled: true, status: 'available', runtimeRequired: true},
|
||||
@@ -77,8 +71,14 @@ export function awsLoadBalancingSchema(): ServiceSchema {
|
||||
status: 'available',
|
||||
runtimeRequired: true,
|
||||
},
|
||||
{name: 'update', label: 'Set security groups', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Delete', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
itemActions: [
|
||||
{name: 'list', label: 'List listeners', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'put', label: 'Create listener', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Delete listener', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
},
|
||||
filters: loadBalancerFilters,
|
||||
columns: loadBalancerColumns,
|
||||
|
||||
@@ -78,6 +78,13 @@ export const SERVICE_CATALOG = {
|
||||
order: 40,
|
||||
},
|
||||
storage: {displayName: 'Storage', iconKey: 'storage', group: 'Storage', order: 10},
|
||||
ecr: {
|
||||
displayName: 'Elastic Container Registry',
|
||||
displayNameByCloud: {aws: 'ECR'},
|
||||
iconKey: 'ecr',
|
||||
group: 'Storage',
|
||||
order: 20,
|
||||
},
|
||||
database: {displayName: 'Database', iconKey: 'database', group: 'Databases', order: 10},
|
||||
nosql: {
|
||||
displayName: 'NoSQL',
|
||||
@@ -104,6 +111,13 @@ export const SERVICE_CATALOG = {
|
||||
group: 'Networking',
|
||||
order: 20,
|
||||
},
|
||||
targetgroups: {
|
||||
displayName: 'Target Groups',
|
||||
displayNameByCloud: {aws: 'ELB Target Groups'},
|
||||
iconKey: 'loadbalancing',
|
||||
group: 'Networking',
|
||||
order: 30,
|
||||
},
|
||||
messaging: {
|
||||
displayName: 'Messaging',
|
||||
displayNameByCloud: {aws: 'SQS', gcp: 'Pub/Sub'},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -433,6 +433,7 @@ export interface AppConfigDeployment {
|
||||
|
||||
export interface ResourceQuery {
|
||||
search?: string
|
||||
state?: string
|
||||
}
|
||||
|
||||
export interface CreateResourceInput {
|
||||
@@ -643,6 +644,8 @@ export interface CloudServiceAdapter {
|
||||
get(id: string): Promise<CloudResource | null>
|
||||
create(input: CreateResourceInput): 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>
|
||||
listObjects?(resourceId: string, prefix?: string): Promise<StorageObjectList>
|
||||
putObject?(resourceId: string, key: string, body: Uint8Array, contentType: string): Promise<void>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {AwsParameterStoreAdapter} from './adapter-aws/AwsParameterStoreAdapter'
|
||||
import {AwsKmsAdapter} from './adapter-aws/AwsKmsAdapter'
|
||||
import {AwsStepFunctionsAdapter} from './adapter-aws/AwsStepFunctionsAdapter'
|
||||
import {AwsLoadBalancingAdapter} from './adapter-aws/AwsLoadBalancingAdapter'
|
||||
import {AwsTargetGroupAdapter} from './adapter-aws/AwsTargetGroupAdapter'
|
||||
import {AwsEventBridgeAdapter} from './adapter-aws/AwsEventBridgeAdapter'
|
||||
import {AwsIamAdapter} from './adapter-aws/AwsIamAdapter'
|
||||
import {AwsApiGatewayAdapter} from './adapter-aws/AwsApiGatewayAdapter'
|
||||
@@ -37,6 +38,7 @@ import {AwsSecretsAdapter} from './adapter-aws/AwsSecretsAdapter'
|
||||
import {AwsSesAdapter} from './adapter-aws/AwsSesAdapter'
|
||||
import {AwsAppConfigAdapter} from './adapter-aws/AwsAppConfigAdapter'
|
||||
import {AwsSnsAdapter} from './adapter-aws/AwsSnsAdapter'
|
||||
import {AwsEcrAdapter} from './adapter-aws/AwsEcrAdapter'
|
||||
import {awsClientsForAccount, resolveAccountId} from './aws'
|
||||
import {createEc2Service} from './services/ec2'
|
||||
import {createEksService} from './services/eks'
|
||||
@@ -67,7 +69,8 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
||||
new AwsParameterStoreAdapter(clients.ssm),
|
||||
new AwsKmsAdapter(clients.kms),
|
||||
new AwsStepFunctionsAdapter(clients.sfn),
|
||||
new AwsLoadBalancingAdapter(clients.elbv2),
|
||||
new AwsLoadBalancingAdapter(clients.elbv2, clients.ec2),
|
||||
new AwsTargetGroupAdapter(clients.elbv2, clients.ec2),
|
||||
new AwsEventBridgeAdapter(clients.eventbridge, clients.lambda),
|
||||
new AwsIamAdapter(clients.iam),
|
||||
new AwsApiGatewayAdapter(clients.apiGateway, clients.lambda, resolveAccountId(accountId)),
|
||||
@@ -76,6 +79,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
||||
new AwsSesAdapter(),
|
||||
new AwsAppConfigAdapter(clients.appConfig),
|
||||
new AwsSnsAdapter(clients.sns),
|
||||
new AwsEcrAdapter(clients.ecr),
|
||||
new AzureStorageAdapter(),
|
||||
new AzureServiceBusAdapter(),
|
||||
new AzureDatabaseAdapter(),
|
||||
|
||||
@@ -81,6 +81,16 @@ describe('GET /clouds/:cloud/services/:service/resources - search query param (#
|
||||
expect(adapter.capturedQuery?.search).toBeUndefined()
|
||||
})
|
||||
|
||||
test('forwards the state param when provided', async () => {
|
||||
const adapter = new StubStorageAdapter()
|
||||
const app = buildApp(adapter)
|
||||
|
||||
const response = await app.request('/aws/services/storage/resources?state=running')
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(adapter.capturedQuery?.state).toBe('running')
|
||||
})
|
||||
|
||||
test('filters resources by search term via adapter', async () => {
|
||||
const adapter = new StubStorageAdapter()
|
||||
const app = buildApp(adapter)
|
||||
|
||||
@@ -82,6 +82,14 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
||||
return c.json(schema)
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/:service/dependencies', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
const serviceType = c.req.param('service') as CloudServiceType
|
||||
if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
return withRuntime(c, async () => c.json(await svc(c).dependencies(cloud, serviceType)))
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/database/snapshots', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
@@ -119,7 +127,10 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
||||
if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const resources = await svc(c).listResources(cloud, serviceType, {search: c.req.query('search')})
|
||||
const resources = await svc(c).listResources(cloud, serviceType, {
|
||||
search: c.req.query('search'),
|
||||
state: c.req.query('state'),
|
||||
})
|
||||
return c.json(resources)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,6 +30,7 @@ const baseInstance: Ec2Instance = {
|
||||
instanceType: 't3.micro',
|
||||
availabilityZone: 'us-east-1a',
|
||||
securityGroups: [],
|
||||
networkInterfaces: [],
|
||||
tags: [],
|
||||
}
|
||||
|
||||
|
||||
@@ -358,6 +358,12 @@ export class CloudProxyService {
|
||||
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> {
|
||||
await this.requireAdapter(cloud, service).delete(id)
|
||||
}
|
||||
|
||||
@@ -94,6 +94,40 @@ describe('ec2Service.runInstance', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ec2Service.describeInstance', () => {
|
||||
test('maps attached network interfaces and their addresses', async () => {
|
||||
const service = createEc2Service(fakeClient({
|
||||
DescribeInstancesCommand: {
|
||||
Reservations: [{Instances: [{
|
||||
InstanceId: 'i-networked',
|
||||
NetworkInterfaces: [{
|
||||
NetworkInterfaceId: 'eni-123',
|
||||
Attachment: {DeviceIndex: 0},
|
||||
Status: 'in-use',
|
||||
PrivateIpAddress: '10.0.1.10',
|
||||
Association: {PublicIp: '198.51.100.10'},
|
||||
Groups: [{GroupId: 'sg-123', GroupName: 'web'}],
|
||||
}],
|
||||
}]}],
|
||||
},
|
||||
}))
|
||||
|
||||
const instance = await service.describeInstance('i-networked')
|
||||
|
||||
expect(instance.networkInterfaces).toEqual([{
|
||||
networkInterfaceId: 'eni-123',
|
||||
deviceIndex: 0,
|
||||
status: 'in-use',
|
||||
privateIpAddress: '10.0.1.10',
|
||||
publicIpAddress: '198.51.100.10',
|
||||
privateDnsName: undefined,
|
||||
subnetId: undefined,
|
||||
vpcId: undefined,
|
||||
securityGroups: [{id: 'sg-123', name: 'web'}],
|
||||
}])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ec2Service lifecycle', () => {
|
||||
test('startInstance sends StartInstancesCommand with correct id', async () => {
|
||||
const sentCmds: unknown[] = []
|
||||
|
||||
@@ -77,6 +77,18 @@ export type Ec2SecurityGroupRef = {
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type Ec2NetworkInterface = {
|
||||
networkInterfaceId: string
|
||||
deviceIndex?: number
|
||||
status?: string
|
||||
privateIpAddress?: string
|
||||
publicIpAddress?: string
|
||||
privateDnsName?: string
|
||||
subnetId?: string
|
||||
vpcId?: string
|
||||
securityGroups: Ec2SecurityGroupRef[]
|
||||
}
|
||||
|
||||
export type Ec2IpPermission = {
|
||||
protocol: string
|
||||
fromPort: number | null
|
||||
@@ -126,6 +138,7 @@ export type Ec2Instance = {
|
||||
architecture?: string
|
||||
platform?: string
|
||||
securityGroups: Ec2SecurityGroupRef[]
|
||||
networkInterfaces: Ec2NetworkInterface[]
|
||||
tags: Ec2Tag[]
|
||||
}
|
||||
|
||||
@@ -306,6 +319,20 @@ function toEc2Instance(instance: Instance): Ec2Instance {
|
||||
id: sg.GroupId,
|
||||
name: sg.GroupName,
|
||||
})),
|
||||
networkInterfaces: (instance.NetworkInterfaces ?? []).map((networkInterface) => ({
|
||||
networkInterfaceId: networkInterface.NetworkInterfaceId ?? '',
|
||||
deviceIndex: networkInterface.Attachment?.DeviceIndex,
|
||||
status: networkInterface.Status,
|
||||
privateIpAddress: networkInterface.PrivateIpAddress,
|
||||
publicIpAddress: networkInterface.Association?.PublicIp,
|
||||
privateDnsName: networkInterface.PrivateDnsName,
|
||||
subnetId: networkInterface.SubnetId,
|
||||
vpcId: networkInterface.VpcId,
|
||||
securityGroups: (networkInterface.Groups ?? []).map((group) => ({
|
||||
id: group.GroupId,
|
||||
name: group.GroupName,
|
||||
})),
|
||||
})),
|
||||
tags,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user