c5e511fbfa
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.
161 lines
9.1 KiB
TypeScript
161 lines
9.1 KiB
TypeScript
import {describe, expect, test} from 'bun:test'
|
|
import {
|
|
type AutoScalingClient,
|
|
CreateAutoScalingGroupCommand,
|
|
DeleteAutoScalingGroupCommand,
|
|
DescribeAutoScalingGroupsCommand,
|
|
DescribeScalingActivitiesCommand,
|
|
TerminateInstanceInAutoScalingGroupCommand,
|
|
UpdateAutoScalingGroupCommand,
|
|
} from '@aws-sdk/client-auto-scaling'
|
|
import {DescribeSubnetsCommand, type EC2Client} from '@aws-sdk/client-ec2'
|
|
import {AwsAutoScalingAdapter} from './AwsAutoScalingAdapter'
|
|
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
|
|
|
type SendResult = Record<string, unknown>
|
|
|
|
function stub(handler: (command: object) => SendResult | Promise<SendResult>) {
|
|
const sent: object[] = []
|
|
const client = {
|
|
async send(command: object) {
|
|
sent.push(command)
|
|
return handler(command)
|
|
},
|
|
}
|
|
return {client, sent}
|
|
}
|
|
|
|
const group = {
|
|
AutoScalingGroupName: 'web-asg',
|
|
AutoScalingGroupARN: 'arn:aws:autoscaling:us-east-1:000000000000:autoScalingGroup:web-asg',
|
|
LaunchTemplate: {LaunchTemplateName: 'web-lt', Version: '$Latest'},
|
|
MinSize: 1, MaxSize: 3, DesiredCapacity: 2,
|
|
VPCZoneIdentifier: 'subnet-a,subnet-b',
|
|
HealthCheckType: 'EC2',
|
|
Instances: [
|
|
{InstanceId: 'i-b8736c8b4a8c129a3', AvailabilityZone: 'us-east-1a', LifecycleState: 'InService', HealthStatus: 'Healthy', InstanceType: 't3.micro'},
|
|
{InstanceId: 'i-a3155c876867976b1', AvailabilityZone: 'us-east-1b', LifecycleState: 'Pending', HealthStatus: 'Healthy', InstanceType: 't3.micro'},
|
|
],
|
|
CreatedTime: new Date('2026-10-25T16:49:54Z'),
|
|
}
|
|
const activity = {ActivityId: 'a1', Description: 'Launching a new EC2 instance: i-b8736c8b4a8c129a3', Cause: 'An instance was started in response to a desired capacity change.', StatusCode: 'Successful', StartTime: new Date('2026-10-25T16:50:09Z')}
|
|
|
|
function adapterWith(handler: (command: object) => SendResult | undefined) {
|
|
const autoscaling = stub((command) => handler(command) ?? (
|
|
command instanceof DescribeAutoScalingGroupsCommand ? {AutoScalingGroups: [group]}
|
|
: command instanceof DescribeScalingActivitiesCommand ? {Activities: [activity]}
|
|
: {}))
|
|
const ec2 = stub((command) => command instanceof DescribeSubnetsCommand ? {Subnets: [{SubnetId: 'subnet-default-b'}, {SubnetId: 'subnet-default-a'}]} : {})
|
|
return {
|
|
sent: autoscaling.sent,
|
|
ec2Sent: ec2.sent,
|
|
adapter: new AwsAutoScalingAdapter(autoscaling.client as unknown as AutoScalingClient, ec2.client as unknown as EC2Client),
|
|
}
|
|
}
|
|
|
|
describe('AwsAutoScalingAdapter', () => {
|
|
test('lists groups across pages with their capacity and template', async () => {
|
|
const {adapter} = adapterWith((command) => {
|
|
if (!(command instanceof DescribeAutoScalingGroupsCommand)) return undefined
|
|
return command.input.NextToken
|
|
? {AutoScalingGroups: [{...group, AutoScalingGroupName: 'jobs', LaunchTemplate: {LaunchTemplateName: 'jobs-lt'}, Instances: []}]}
|
|
: {AutoScalingGroups: [group], NextToken: 'n'}
|
|
})
|
|
|
|
const groups = await adapter.list()
|
|
expect(groups.map((g) => [g.name, g.metadata.capacity, g.metadata.desiredCapacity, g.metadata.launchTemplateName])).toEqual([
|
|
['web-asg', '1 in service', 2, 'web-lt'],
|
|
['jobs', '0 in service', 2, 'jobs-lt'],
|
|
])
|
|
expect((await adapter.list({search: 'jobs-lt'})).map((g) => g.name)).toEqual(['jobs'])
|
|
})
|
|
|
|
test('describes the instances and recent scaling activity', async () => {
|
|
const {adapter} = adapterWith(() => undefined)
|
|
const detail = await adapter.get('web-asg')
|
|
expect(detail?.metadata.instances).toEqual([
|
|
{id: 'i-b8736c8b4a8c129a3', availabilityZone: 'us-east-1a', lifecycleState: 'InService', health: 'Healthy', instanceType: 't3.micro', protectedFromScaleIn: false},
|
|
{id: 'i-a3155c876867976b1', availabilityZone: 'us-east-1b', lifecycleState: 'Pending', health: 'Healthy', instanceType: 't3.micro', protectedFromScaleIn: false},
|
|
])
|
|
expect(detail?.metadata.activities).toEqual([{id: 'a1', description: activity.Description, cause: activity.Cause, status: 'Successful', statusMessage: null, startTime: '2026-10-25T16:50:09.000Z'}])
|
|
})
|
|
|
|
test('keeps the group readable when its activities cannot be read', async () => {
|
|
const {adapter} = adapterWith((command) => {
|
|
if (command instanceof DescribeScalingActivitiesCommand) throw new Error('throttled')
|
|
return undefined
|
|
})
|
|
expect((await adapter.get('web-asg'))?.metadata.activitiesUnavailable).toBe(true)
|
|
})
|
|
|
|
test('a missing group is null', async () => {
|
|
const {adapter} = adapterWith((command) => command instanceof DescribeAutoScalingGroupsCommand ? {AutoScalingGroups: []} : undefined)
|
|
expect(await adapter.get('nope')).toBeNull()
|
|
})
|
|
|
|
test('creates a group in the default subnets when none are given', async () => {
|
|
const {adapter, sent} = adapterWith(() => undefined)
|
|
|
|
await adapter.create({values: {name: 'web-asg', launchTemplate: 'web-lt', minSize: '1', desiredCapacity: '2', maxSize: '3'}})
|
|
expect((sent[0] as CreateAutoScalingGroupCommand).input).toEqual({
|
|
AutoScalingGroupName: 'web-asg',
|
|
LaunchTemplate: {LaunchTemplateName: 'web-lt', Version: '$Latest'},
|
|
MinSize: 1, MaxSize: 3, DesiredCapacity: 2,
|
|
VPCZoneIdentifier: 'subnet-default-a,subnet-default-b',
|
|
HealthCheckType: 'EC2',
|
|
})
|
|
})
|
|
|
|
test('creates from a template ID with given subnets, target groups and ELB health checks', async () => {
|
|
const {adapter, sent, ec2Sent} = adapterWith(() => undefined)
|
|
const targetGroup = 'arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/web/abc'
|
|
|
|
await adapter.create({values: {name: 'web-asg', launchTemplate: 'lt-ba034c5aa8346f602', launchTemplateVersion: '2', minSize: '0', maxSize: '2', subnets: 'subnet-a, subnet-b', targetGroupArns: targetGroup, healthCheckType: 'ELB'}})
|
|
expect((sent[0] as CreateAutoScalingGroupCommand).input).toMatchObject({
|
|
LaunchTemplate: {LaunchTemplateId: 'lt-ba034c5aa8346f602', Version: '2'},
|
|
DesiredCapacity: 0,
|
|
VPCZoneIdentifier: 'subnet-a,subnet-b',
|
|
TargetGroupARNs: [targetGroup],
|
|
HealthCheckType: 'ELB',
|
|
HealthCheckGracePeriod: 300,
|
|
})
|
|
expect(ec2Sent).toHaveLength(0)
|
|
})
|
|
|
|
test('refuses capacities AWS would reject, before calling it', async () => {
|
|
const {adapter, sent} = adapterWith(() => undefined)
|
|
const base = {name: 'g', launchTemplate: 'web-lt'}
|
|
await expect(adapter.create({values: {...base, minSize: '3', maxSize: '1'}})).rejects.toThrow('minSize cannot be larger than maxSize')
|
|
await expect(adapter.create({values: {...base, minSize: '1', desiredCapacity: '5', maxSize: '3'}})).rejects.toThrow('desiredCapacity must be between')
|
|
await expect(adapter.create({values: {...base, minSize: '0', maxSize: '500'}})).rejects.toBeInstanceOf(ValidationError)
|
|
await expect(adapter.create({values: {...base, minSize: '', maxSize: '1'}})).rejects.toBeInstanceOf(ValidationError)
|
|
await expect(adapter.create({values: {...base, minSize: '1', maxSize: '1', launchTemplateVersion: 'latest'}})).rejects.toBeInstanceOf(ValidationError)
|
|
await expect(adapter.create({values: {...base, name: 'a:b', minSize: '1', maxSize: '1'}})).rejects.toBeInstanceOf(ValidationError)
|
|
expect(sent).toHaveLength(0)
|
|
})
|
|
|
|
test('changes capacity, keeping the values not given', async () => {
|
|
const {adapter, sent} = adapterWith(() => undefined)
|
|
|
|
await adapter.setAutoScalingCapacity('web-asg', {desiredCapacity: 3})
|
|
expect((sent.find((command) => command instanceof UpdateAutoScalingGroupCommand) as UpdateAutoScalingGroupCommand).input).toEqual({AutoScalingGroupName: 'web-asg', MinSize: 1, DesiredCapacity: 3, MaxSize: 3})
|
|
await expect(adapter.setAutoScalingCapacity('web-asg', {desiredCapacity: 4})).rejects.toThrow('desiredCapacity must be between')
|
|
})
|
|
|
|
test('terminates an instance of the group, replacing it or shrinking the group', async () => {
|
|
const {adapter, sent} = adapterWith(() => undefined)
|
|
|
|
await adapter.terminateAutoScalingInstance('web-asg', 'i-b8736c8b4a8c129a3', true)
|
|
await adapter.terminateAutoScalingInstance('web-asg', 'i-b8736c8b4a8c129a3', false)
|
|
const terminations = sent.filter((command): command is TerminateInstanceInAutoScalingGroupCommand => command instanceof TerminateInstanceInAutoScalingGroupCommand)
|
|
expect(terminations.map((command) => command.input.ShouldDecrementDesiredCapacity)).toEqual([false, true])
|
|
await expect(adapter.terminateAutoScalingInstance('web-asg', 'i-0000000000000000', true)).rejects.toBeInstanceOf(NotFoundError)
|
|
})
|
|
|
|
test('deletes a group together with its instances', async () => {
|
|
const {adapter, sent} = adapterWith(() => undefined)
|
|
await adapter.delete('web-asg')
|
|
expect((sent[0] as DeleteAutoScalingGroupCommand).input).toEqual({AutoScalingGroupName: 'web-asg', ForceDelete: true})
|
|
})
|
|
})
|