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** (`provisioning`). 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 `` 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 { 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 { 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 { 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> { 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 { 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 { 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 { await this.elb.send(new DeleteLoadBalancerCommand({LoadBalancerArn: id})) } } class ListenerStore implements ItemStoreAdapter { constructor(private readonly elb: ElasticLoadBalancingV2Client) {} async listItems(resourceId: string, _page: PageQuery = {}): Promise> { 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): Promise { 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 { 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 `` 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(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] }