feat: fully functioning ALB
This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[] = []
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
<code>{formatMetadataValue(value)}</code>
|
<span className="copy-value">
|
||||||
|
<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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
Generated
+52
-32
@@ -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':
|
||||||
|
|||||||
Reference in New Issue
Block a user