feat: fully functioning ALB

This commit is contained in:
Zakaria
2026-09-26 16:14:24 -04:00
parent 093afb9b2b
commit 8470b8d17a
44 changed files with 2076 additions and 132 deletions
@@ -1,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`)