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 { 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 { const group = await this.describe(id) if (!group) return null return toResource(group, await this.activities(id)) } async create(input: CreateResourceInput): Promise { 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 { await this.autoscaling.send(new DeleteAutoScalingGroupCommand({AutoScalingGroupName: id, ForceDelete: true})) } async setAutoScalingCapacity(id: string, input: AutoScalingCapacityInput): Promise { 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 { 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 { 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 { 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 { 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): 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() : '' }