70cfd4bc96
CI / checks (pull_request) Successful in 26m54s
Create groups from a launch template (in the default subnets unless given), change their minimum, desired and maximum capacity, replace or remove an instance, and follow the launches and terminations TheCloud's reconciler makes. Deleting a group terminates its instances. Scaling policies are not offered: the runtime stores them but its alarms only drive ECS scaling.
228 lines
12 KiB
TypeScript
228 lines
12 KiB
TypeScript
import {
|
|
type Activity,
|
|
type AutoScalingClient,
|
|
type AutoScalingGroup,
|
|
CreateAutoScalingGroupCommand,
|
|
DeleteAutoScalingGroupCommand,
|
|
DescribeAutoScalingGroupsCommand,
|
|
DescribeScalingActivitiesCommand,
|
|
TerminateInstanceInAutoScalingGroupCommand,
|
|
UpdateAutoScalingGroupCommand,
|
|
} from '@aws-sdk/client-auto-scaling'
|
|
import {DescribeSubnetsCommand, type EC2Client} from '@aws-sdk/client-ec2'
|
|
import {AUTO_SCALING_GROUP_NAME_PATTERN, AUTO_SCALING_MAX_INSTANCES, awsAutoScalingSchema} from '../cloud-spi/autoScalingSchema'
|
|
import {NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors'
|
|
import type {AutoScalingCapacityInput, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
|
|
|
/** The most recent activities a group's detail shows. */
|
|
const ACTIVITY_LIMIT = 20
|
|
|
|
/**
|
|
* Talks to EC2 Auto Scaling, verified against Floci core (community, `latest`).
|
|
*
|
|
* Notes from probing the runtime:
|
|
* - Groups really scale: a reconciler runs every 10 seconds and launches or
|
|
* terminates EC2 instances (containers) until the group matches its desired
|
|
* capacity, registering them with the group's target groups, and records each
|
|
* launch and termination as a scaling activity.
|
|
* - Scaling policies are stored but never run: the runtime's alarms drive only
|
|
* Application Auto Scaling (ECS services), so this adapter does not offer them.
|
|
*
|
|
* Groups are keyed by name, which is unique per region.
|
|
*/
|
|
export class AwsAutoScalingAdapter implements CloudServiceAdapter {
|
|
readonly cloud = 'aws' as const
|
|
readonly service = 'autoscaling' as const
|
|
|
|
constructor(private readonly autoscaling: AutoScalingClient, private readonly ec2: EC2Client) {}
|
|
|
|
schema(): ServiceSchema {
|
|
return awsAutoScalingSchema()
|
|
}
|
|
|
|
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
|
const groups: AutoScalingGroup[] = []
|
|
let next: string | undefined
|
|
do {
|
|
const res = await this.autoscaling.send(new DescribeAutoScalingGroupsCommand({MaxRecords: 100, ...(next ? {NextToken: next} : {})}))
|
|
groups.push(...(res.AutoScalingGroups ?? []))
|
|
next = res.NextToken
|
|
} while (next)
|
|
|
|
const search = query.search?.trim().toLowerCase()
|
|
return groups.map((group) => toResource(group)).filter((group) => !search
|
|
|| group.name.toLowerCase().includes(search)
|
|
|| String(group.metadata.launchTemplateName ?? '').toLowerCase().includes(search))
|
|
}
|
|
|
|
async get(id: string): Promise<CloudResource | null> {
|
|
const group = await this.describe(id)
|
|
if (!group) return null
|
|
return toResource(group, await this.activities(id))
|
|
}
|
|
|
|
async create(input: CreateResourceInput): Promise<CloudResource> {
|
|
const values = input.values
|
|
const name = text(values.name)
|
|
if (!new RegExp(AUTO_SCALING_GROUP_NAME_PATTERN).test(name)) throw new ValidationError('name must be 1 to 255 characters, without a colon')
|
|
|
|
const template = text(values.launchTemplate)
|
|
if (!template) throw new ValidationError('launchTemplate is required')
|
|
const version = text(values.launchTemplateVersion) || '$Latest'
|
|
if (!/^(\d+|\$Latest|\$Default)$/.test(version)) throw new ValidationError('launchTemplateVersion must be a version number, $Latest or $Default')
|
|
|
|
const minSize = count(values.minSize, 'minSize')
|
|
const maxSize = count(values.maxSize, 'maxSize')
|
|
const desiredCapacity = text(values.desiredCapacity) ? count(values.desiredCapacity, 'desiredCapacity') : minSize
|
|
checkOrder({minSize, desiredCapacity, maxSize})
|
|
|
|
const subnets = text(values.subnets).split(',').map((subnet) => subnet.trim()).filter(Boolean)
|
|
if (subnets.some((subnet) => !/^subnet-[\w-]+$/.test(subnet))) throw new ValidationError('subnets must be subnet IDs separated by commas')
|
|
const targetGroupArns = text(values.targetGroupArns).split(/\s+/).filter(Boolean)
|
|
if (targetGroupArns.some((arn) => !/^arn:aws[a-z-]*:elasticloadbalancing:.+:targetgroup\/.+$/.test(arn))) {
|
|
throw new ValidationError('targetGroupArns must be target group ARNs, one per line')
|
|
}
|
|
const healthCheckType = values.healthCheckType === 'ELB' ? 'ELB' : 'EC2'
|
|
|
|
await this.autoscaling.send(new CreateAutoScalingGroupCommand({
|
|
AutoScalingGroupName: name,
|
|
LaunchTemplate: {...(template.startsWith('lt-') ? {LaunchTemplateId: template} : {LaunchTemplateName: template}), Version: version},
|
|
MinSize: minSize,
|
|
MaxSize: maxSize,
|
|
DesiredCapacity: desiredCapacity,
|
|
VPCZoneIdentifier: (subnets.length > 0 ? subnets : await this.defaultSubnets()).join(','),
|
|
...(targetGroupArns.length > 0 ? {TargetGroupARNs: targetGroupArns} : {}),
|
|
HealthCheckType: healthCheckType,
|
|
// AWS's default grace period when a load balancer decides health.
|
|
...(healthCheckType === 'ELB' ? {HealthCheckGracePeriod: 300} : {}),
|
|
}))
|
|
|
|
const created = await this.get(name)
|
|
if (!created) throw new RuntimeError(`EC2 Auto Scaling did not return the new group ${name}`)
|
|
return created
|
|
}
|
|
|
|
/** ForceDelete terminates the group's instances too, which is what deleting a group in the console means. */
|
|
async delete(id: string): Promise<void> {
|
|
await this.autoscaling.send(new DeleteAutoScalingGroupCommand({AutoScalingGroupName: id, ForceDelete: true}))
|
|
}
|
|
|
|
async setAutoScalingCapacity(id: string, input: AutoScalingCapacityInput): Promise<CloudResource> {
|
|
const group = await this.describe(id)
|
|
if (!group) throw new NotFoundError(`Auto Scaling group ${id} was not found`)
|
|
|
|
const next = {
|
|
minSize: input.minSize === undefined ? group.MinSize ?? 0 : count(input.minSize, 'minSize'),
|
|
desiredCapacity: input.desiredCapacity === undefined ? group.DesiredCapacity ?? 0 : count(input.desiredCapacity, 'desiredCapacity'),
|
|
maxSize: input.maxSize === undefined ? group.MaxSize ?? 0 : count(input.maxSize, 'maxSize'),
|
|
}
|
|
checkOrder(next)
|
|
|
|
await this.autoscaling.send(new UpdateAutoScalingGroupCommand({
|
|
AutoScalingGroupName: id,
|
|
MinSize: next.minSize,
|
|
DesiredCapacity: next.desiredCapacity,
|
|
MaxSize: next.maxSize,
|
|
}))
|
|
const updated = await this.get(id)
|
|
if (!updated) throw new NotFoundError(`Auto Scaling group ${id} was not found`)
|
|
return updated
|
|
}
|
|
|
|
async terminateAutoScalingInstance(id: string, instanceId: string, replace: boolean): Promise<void> {
|
|
if (!/^i-[0-9a-f]+$/.test(instanceId)) throw new ValidationError('instanceId must be an EC2 instance ID such as i-0abc1234')
|
|
const group = await this.describe(id)
|
|
if (!group?.Instances?.some((instance) => instance.InstanceId === instanceId)) {
|
|
throw new NotFoundError(`Instance ${instanceId} is not in Auto Scaling group ${id}`)
|
|
}
|
|
await this.autoscaling.send(new TerminateInstanceInAutoScalingGroupCommand({InstanceId: instanceId, ShouldDecrementDesiredCapacity: !replace}))
|
|
}
|
|
|
|
private async describe(name: string): Promise<AutoScalingGroup | null> {
|
|
const res = await this.autoscaling.send(new DescribeAutoScalingGroupsCommand({AutoScalingGroupNames: [name]}))
|
|
return res.AutoScalingGroups?.find((group) => group.AutoScalingGroupName === name) ?? null
|
|
}
|
|
|
|
/** Activities are enrichment: a failure leaves the group readable rather than failing the request. */
|
|
private async activities(name: string): Promise<Activity[] | 'unavailable'> {
|
|
try {
|
|
const res = await this.autoscaling.send(new DescribeScalingActivitiesCommand({AutoScalingGroupName: name, MaxRecords: ACTIVITY_LIMIT}))
|
|
return res.Activities ?? []
|
|
} catch {
|
|
return 'unavailable'
|
|
}
|
|
}
|
|
|
|
/** AWS needs subnets or zones; like its console, fall back to the default VPC's subnets. */
|
|
private async defaultSubnets(): Promise<string[]> {
|
|
const res = await this.ec2.send(new DescribeSubnetsCommand({Filters: [{Name: 'default-for-az', Values: ['true']}]}))
|
|
const subnets = (res.Subnets ?? []).map((subnet) => subnet.SubnetId).filter((id): id is string => Boolean(id)).sort()
|
|
if (subnets.length === 0) throw new ValidationError('There is no default VPC to launch into; enter subnet IDs')
|
|
return subnets
|
|
}
|
|
}
|
|
|
|
function toResource(group: AutoScalingGroup, activities?: Activity[] | 'unavailable'): CloudResource {
|
|
const name = group.AutoScalingGroupName ?? ''
|
|
const instances = group.Instances ?? []
|
|
const template = group.LaunchTemplate ?? group.MixedInstancesPolicy?.LaunchTemplate?.LaunchTemplateSpecification
|
|
return {
|
|
id: name,
|
|
name,
|
|
cloud: 'aws',
|
|
service: 'autoscaling',
|
|
type: 'autoScalingGroup',
|
|
region: group.AutoScalingGroupARN?.split(':')[3] || null,
|
|
createdAt: group.CreatedTime ? group.CreatedTime.toISOString() : null,
|
|
status: group.Status ?? null,
|
|
metadata: {
|
|
arn: group.AutoScalingGroupARN,
|
|
minSize: group.MinSize ?? 0,
|
|
maxSize: group.MaxSize ?? 0,
|
|
desiredCapacity: group.DesiredCapacity ?? 0,
|
|
capacity: `${instances.filter((instance) => instance.LifecycleState === 'InService').length} in service`,
|
|
launchTemplateName: template?.LaunchTemplateName ?? template?.LaunchTemplateId ?? group.LaunchConfigurationName ?? null,
|
|
launchTemplateVersion: template?.Version ?? null,
|
|
subnets: (group.VPCZoneIdentifier ?? '').split(',').filter(Boolean),
|
|
targetGroupArns: group.TargetGroupARNs ?? [],
|
|
healthCheckType: group.HealthCheckType ?? null,
|
|
suspendedProcesses: (group.SuspendedProcesses ?? []).map((process) => process.ProcessName).filter(Boolean),
|
|
instances: instances.map((instance) => ({
|
|
id: instance.InstanceId ?? '',
|
|
availabilityZone: instance.AvailabilityZone ?? null,
|
|
lifecycleState: instance.LifecycleState ?? null,
|
|
health: instance.HealthStatus ?? null,
|
|
instanceType: instance.InstanceType ?? null,
|
|
protectedFromScaleIn: instance.ProtectedFromScaleIn ?? false,
|
|
})),
|
|
...(activities === 'unavailable' ? {activitiesUnavailable: true} : activities ? {
|
|
activities: activities.map((activity) => ({
|
|
id: activity.ActivityId ?? '',
|
|
description: activity.Description ?? '',
|
|
cause: activity.Cause ?? '',
|
|
status: activity.StatusCode ?? null,
|
|
statusMessage: activity.StatusMessage ?? null,
|
|
startTime: activity.StartTime ? activity.StartTime.toISOString() : null,
|
|
})),
|
|
} : {}),
|
|
},
|
|
}
|
|
}
|
|
|
|
function checkOrder({minSize, desiredCapacity, maxSize}: Required<AutoScalingCapacityInput>): void {
|
|
if (minSize > maxSize) throw new ValidationError('minSize cannot be larger than maxSize')
|
|
if (desiredCapacity < minSize || desiredCapacity > maxSize) throw new ValidationError('desiredCapacity must be between minSize and maxSize')
|
|
}
|
|
|
|
function count(value: unknown, field: string): number {
|
|
const raw = typeof value === 'number' ? value : Number(text(value))
|
|
if (!Number.isInteger(raw) || raw < 0 || raw > AUTO_SCALING_MAX_INSTANCES || (typeof value === 'string' && !text(value))) {
|
|
throw new ValidationError(`${field} must be a whole number from 0 to ${AUTO_SCALING_MAX_INSTANCES}`)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
function text(value: unknown): string {
|
|
return typeof value === 'string' ? value.trim() : ''
|
|
}
|