284 lines
12 KiB
TypeScript
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]
|
|
}
|