feat: fully functioning ALB
This commit is contained in:
@@ -1,12 +1,19 @@
|
||||
import {
|
||||
CreateListenerCommand,
|
||||
CreateLoadBalancerCommand,
|
||||
DeleteListenerCommand,
|
||||
DeleteLoadBalancerCommand,
|
||||
DescribeListenersCommand,
|
||||
DescribeLoadBalancersCommand,
|
||||
SetSecurityGroupsCommand,
|
||||
type ElasticLoadBalancingV2Client,
|
||||
type Listener,
|
||||
type LoadBalancer,
|
||||
} from '@aws-sdk/client-elastic-load-balancing-v2'
|
||||
import {DescribeSecurityGroupsCommand, DescribeSubnetsCommand, DescribeVpcsCommand, type EC2Client} from '@aws-sdk/client-ec2'
|
||||
import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections'
|
||||
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import {ELB_NAME_PATTERN, ELB_SCHEMES, ELB_TYPES, awsLoadBalancingSchema} from '../cloud-spi/loadBalancingSchema'
|
||||
import {ELB_NAME_PATTERN, ELB_SCHEMES, awsLoadBalancingSchema} from '../cloud-spi/loadBalancingSchema'
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
@@ -39,8 +46,11 @@ import type {
|
||||
export class AwsLoadBalancingAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' as const
|
||||
readonly service = 'loadbalancing' as const
|
||||
readonly items: ItemStoreAdapter
|
||||
|
||||
constructor(private readonly elb: ElasticLoadBalancingV2Client) {}
|
||||
constructor(private readonly elb: ElasticLoadBalancingV2Client, private readonly ec2: EC2Client) {
|
||||
this.items = new ListenerStore(elb)
|
||||
}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return awsLoadBalancingSchema()
|
||||
@@ -78,32 +88,93 @@ export class AwsLoadBalancingAdapter implements CloudServiceAdapter {
|
||||
)
|
||||
}
|
||||
|
||||
// Only the count is enforced. Whether the subnets sit in different
|
||||
// availability zones is ELB's rule, and checking it here would mean calling
|
||||
// EC2 DescribeSubnets from the load balancing adapter — real coupling for a
|
||||
// validation nicety. So the message claims only what this check does; the
|
||||
// schema field carries the AZ requirement as guidance.
|
||||
const subnets = splitIds(requiredString(input.values.subnets, 'subnets'))
|
||||
if (subnets.length < 2) {
|
||||
throw new ValidationError('subnets must list at least two subnet ids')
|
||||
}
|
||||
|
||||
const type = optionalOneOf(input.values.type, ELB_TYPES, 'type') ?? 'application'
|
||||
const securityGroups = splitIds(requiredString(input.values.securityGroups, 'securityGroups'))
|
||||
await this.validateSubnets(subnets)
|
||||
if (securityGroups.length === 0) throw new ValidationError('securityGroups must list at least one security group id')
|
||||
const scheme = optionalOneOf(input.values.scheme, ELB_SCHEMES, 'scheme') ?? 'internet-facing'
|
||||
|
||||
const res = await this.elb.send(
|
||||
new CreateLoadBalancerCommand({Name: name, Subnets: subnets, Type: type, Scheme: scheme}),
|
||||
new CreateLoadBalancerCommand({Name: name, Subnets: subnets, SecurityGroups: securityGroups, Type: 'application', Scheme: scheme}),
|
||||
)
|
||||
const created = res.LoadBalancers?.[0]
|
||||
if (!created) throw new RuntimeError(`ELB did not return the created load balancer for ${name}`)
|
||||
return toResource(created)
|
||||
}
|
||||
|
||||
async dependencies(): Promise<Record<string, unknown>> {
|
||||
const [vpcs, subnets, securityGroups] = await Promise.all([
|
||||
this.ec2.send(new DescribeVpcsCommand({})),
|
||||
this.ec2.send(new DescribeSubnetsCommand({})),
|
||||
this.ec2.send(new DescribeSecurityGroupsCommand({})),
|
||||
])
|
||||
return {
|
||||
vpcs: (vpcs.Vpcs ?? []).map((vpc) => ({id: vpc.VpcId ?? '', cidrBlock: vpc.CidrBlock ?? ''})),
|
||||
subnets: (subnets.Subnets ?? []).map((subnet) => ({id: subnet.SubnetId ?? '', vpcId: subnet.VpcId ?? '', availabilityZone: subnet.AvailabilityZone ?? '', cidrBlock: subnet.CidrBlock ?? ''})),
|
||||
securityGroups: (securityGroups.SecurityGroups ?? []).map((group) => ({id: group.GroupId ?? '', vpcId: group.VpcId ?? '', name: group.GroupName ?? '', description: group.Description ?? ''})),
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, input: CreateResourceInput): Promise<CloudResource> {
|
||||
const securityGroups = splitIds(requiredString(input.values.securityGroups, 'securityGroups'))
|
||||
if (securityGroups.length === 0) throw new ValidationError('securityGroups must list at least one security group id')
|
||||
await this.elb.send(new SetSecurityGroupsCommand({LoadBalancerArn: id, SecurityGroups: securityGroups}))
|
||||
const updated = await this.get(id)
|
||||
if (!updated) throw new RuntimeError(`ELB did not return the updated load balancer ${id}`)
|
||||
return updated
|
||||
}
|
||||
|
||||
private async validateSubnets(subnetIds: string[]): Promise<void> {
|
||||
if (subnetIds.length < 2) throw new ValidationError('subnets must list at least two subnet ids')
|
||||
const res = await this.ec2.send(new DescribeSubnetsCommand({SubnetIds: subnetIds}))
|
||||
const zones = new Set((res.Subnets ?? []).map((subnet) => subnet.AvailabilityZone).filter(Boolean))
|
||||
if (res.Subnets?.length !== subnetIds.length || zones.size < 2) {
|
||||
throw new ValidationError('subnets must include at least two subnets in distinct availability zones')
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.elb.send(new DeleteLoadBalancerCommand({LoadBalancerArn: id}))
|
||||
}
|
||||
}
|
||||
|
||||
class ListenerStore implements ItemStoreAdapter {
|
||||
constructor(private readonly elb: ElasticLoadBalancingV2Client) {}
|
||||
|
||||
async listItems(resourceId: string, _page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
|
||||
const listeners: Listener[] = []
|
||||
let marker: string | undefined
|
||||
|
||||
do {
|
||||
const response = await this.elb.send(new DescribeListenersCommand({LoadBalancerArn: resourceId, ...(marker ? {Marker: marker} : {})}))
|
||||
listeners.push(...(response.Listeners ?? []))
|
||||
marker = response.NextMarker || undefined
|
||||
} while (marker)
|
||||
|
||||
return {items: listeners.flatMap(toListenerItem), nextCursor: null}
|
||||
}
|
||||
|
||||
async putItem(resourceId: string, body: Record<string, unknown>): Promise<ChildItem> {
|
||||
const port = requiredPort(body.port)
|
||||
const targetGroupArn = requiredString(body.targetGroupArn, 'targetGroupArn')
|
||||
const response = await this.elb.send(new CreateListenerCommand({
|
||||
LoadBalancerArn: resourceId,
|
||||
Protocol: 'HTTP',
|
||||
Port: port,
|
||||
DefaultActions: [{Type: 'forward', TargetGroupArn: targetGroupArn}],
|
||||
}))
|
||||
const listener = response.Listeners?.[0]
|
||||
if (!listener) throw new RuntimeError(`ELB did not return the listener created on ${resourceId}`)
|
||||
const item = toListenerItem(listener)[0]
|
||||
if (!item) throw new RuntimeError(`ELB returned a listener without an ARN for ${resourceId}`)
|
||||
return item
|
||||
}
|
||||
|
||||
async deleteItem(_resourceId: string, itemId: string): Promise<void> {
|
||||
await this.elb.send(new DeleteListenerCommand({ListenerArn: itemId}))
|
||||
}
|
||||
}
|
||||
|
||||
function toResource(balancer: LoadBalancer): CloudResource {
|
||||
const arn = balancer.LoadBalancerArn ?? ''
|
||||
const zones = balancer.AvailabilityZones ?? []
|
||||
@@ -134,6 +205,24 @@ function toResource(balancer: LoadBalancer): CloudResource {
|
||||
}
|
||||
}
|
||||
|
||||
function toListenerItem(listener: Listener): ChildItem[] {
|
||||
const arn = listener.ListenerArn
|
||||
if (!arn) return []
|
||||
const action = listener.DefaultActions?.find((candidate) => candidate.Type === 'forward')
|
||||
return [{
|
||||
id: arn,
|
||||
collectionId: null,
|
||||
timestamp: null,
|
||||
body: {
|
||||
arn,
|
||||
protocol: listener.Protocol ?? null,
|
||||
port: listener.Port ?? null,
|
||||
targetGroupArn: action?.TargetGroupArn ?? null,
|
||||
},
|
||||
metadata: {},
|
||||
}]
|
||||
}
|
||||
|
||||
/** `arn:aws:elasticloadbalancing:us-east-1:...` — region is field 4. */
|
||||
function regionFromArn(arn: string): string | null {
|
||||
return arn.split(':')[3] || null
|
||||
@@ -176,6 +265,15 @@ function requiredString(value: unknown, field: string): string {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function requiredPort(value: unknown): number {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') throw new ValidationError('port is required')
|
||||
const port = typeof value === 'number' ? value : Number(value.trim())
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new ValidationError('port must be an integer from 1 to 65535')
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
function optionalOneOf<T extends readonly string[]>(value: unknown, allowed: T, field: string): T[number] | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`)
|
||||
|
||||
Reference in New Issue
Block a user