Files
floci-ui/packages/api/src/adapter-aws/AwsLoadBalancingAdapter.ts
T
2026-09-26 16:14:24 -04:00

284 lines
12 KiB
TypeScript

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, awsLoadBalancingSchema} from '../cloud-spi/loadBalancingSchema'
import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
/**
* Talks to Elastic Load Balancing — verified against Floci core 1.5.33.
*
* Notes from probing the runtime:
* - **Only ELBv2 is implemented.** A `Version=2012-06-01` request comes back in the
* `2015-12-01` namespace, so there is no separate classic ELB to model.
* - `State` is **nested** (`<State><Code>provisioning</Code></State>`). Reading
* `State` directly yields an object and the column renders blank.
* - A missing load balancer is `LoadBalancerNotFound` with HTTP **400**, not 404,
* so `get` matches on the error name. `awsErrors.ts` now maps that name (and
* `TargetGroupNotFound`) so the route reports 404 rather than a validation error.
* - The runtime returns `<NextMarker></NextMarker>` at the end of the list, which
* the SDK surfaces as an empty string — paging must stop on falsy, not on
* undefined, or it spins forever.
* - A new load balancer reports `provisioning` and then reaches `active`; the
* state is surfaced as-is rather than waited on.
*
* Target groups are deliberately excluded: they are a second resource kind in this
* category and would need the `kind` facet from `ResourceQuery.filters` (#162).
* Shipping load balancers alone keeps this PR independent of that one.
*/
export class AwsLoadBalancingAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'loadbalancing' as const
readonly items: ItemStoreAdapter
constructor(private readonly elb: ElasticLoadBalancingV2Client, private readonly ec2: EC2Client) {
this.items = new ListenerStore(elb)
}
schema(): ServiceSchema {
return awsLoadBalancingSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const balancers: LoadBalancer[] = []
let marker: string | undefined
do {
const res = await this.elb.send(new DescribeLoadBalancersCommand(marker ? {Marker: marker} : {}))
balancers.push(...(res.LoadBalancers ?? []))
marker = res.NextMarker || undefined
} while (marker)
return filterBySearch(balancers.map(toResource), query.search)
}
async get(id: string): Promise<CloudResource | null> {
try {
const res = await this.elb.send(new DescribeLoadBalancersCommand({LoadBalancerArns: [id]}))
const balancer = res.LoadBalancers?.[0]
return balancer ? toResource(balancer) : 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 subnets = splitIds(requiredString(input.values.subnets, 'subnets'))
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, 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 ?? []
return {
id: arn,
name: balancer.LoadBalancerName ?? '',
cloud: 'aws',
service: 'loadbalancing',
type: 'load-balancer',
region: regionFromArn(arn),
createdAt: balancer.CreatedTime ? balancer.CreatedTime.toISOString() : null,
status: balancer.State?.Code ?? null,
metadata: {
arn,
dnsName: balancer.DNSName,
/** `type` is taken by CloudResource, so the ELB type lives here. */
lbType: balancer.Type,
scheme: balancer.Scheme,
vpcId: balancer.VpcId,
stateReason: balancer.State?.Reason,
subnets: zones.map((zone) => zone.SubnetId).filter((id): id is string => Boolean(id)),
availabilityZones: zones.map((zone) => zone.ZoneName).filter((z): z is string => Boolean(z)),
securityGroups: balancer.SecurityGroups,
ipAddressType: balancer.IpAddressType,
canonicalHostedZoneId: balancer.CanonicalHostedZoneId,
},
}
}
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
}
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),
)
}
/** Accepts the comma separated form the schema asks for, ignoring blanks. */
function splitIds(raw: string): string[] {
return raw
.split(',')
.map((part) => part.trim())
.filter(Boolean)
}
/**
* The wire `<Code>` is `LoadBalancerNotFound`, but the SDK names the modelled error
* `LoadBalancerNotFoundException`. Matching only the wire code passed every stubbed
* test and still returned 400 against the live runtime, so both are accepted.
*/
function isMissing(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false
const name = (error as {name?: string}).name
return name === 'LoadBalancerNotFound' || name === 'LoadBalancerNotFoundException'
}
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 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`)
const raw = value.trim()
if (!allowed.includes(raw)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
return raw as T[number]
}