feat(api): add EC2 Auto Scaling groups to the cloud explorer
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.
This commit is contained in:
Zakaria
2026-10-25 13:02:49 -04:00
parent 12b50f4868
commit 70cfd4bc96
23 changed files with 902 additions and 1 deletions
+1
View File
@@ -58,6 +58,7 @@ cd packages/api && bun run scripts/service-matrix.ts
| Group | Service | AWS | Azure | GCP | | Group | Service | AWS | Azure | GCP |
|---|---|---|---|---| |---|---|---|---|---|
| Compute | Compute | Yes (list, inspect, create, delete) | Yes (list, inspect, create, delete) | No | | Compute | Compute | Yes (list, inspect, create, delete) | Yes (list, inspect, create, delete) | No |
| Compute | EC2 Auto Scaling / Auto Scaling | Yes (list, create, delete, inspect) | No | No |
| Compute | EKS / AKS / GKE | Yes (list, inspect) | Yes (list, inspect) | Yes (list, create, inspect, delete) | | Compute | EKS / AKS / GKE | Yes (list, inspect) | Yes (list, inspect) | Yes (list, create, inspect, delete) |
| Compute | Serverless | Yes (list, create, update, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) | | Compute | Serverless | Yes (list, create, update, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) |
| Compute | Containers / Cloud Run | No | No | Yes (list, create, delete, inspect) | | Compute | Containers / Cloud Run | No | No | Yes (list, create, delete, inspect) |
+1
View File
@@ -14,6 +14,7 @@
"@aws-sdk/client-api-gateway": "^3.1076.0", "@aws-sdk/client-api-gateway": "^3.1076.0",
"@aws-sdk/client-apigatewayv2": "^3.1076.0", "@aws-sdk/client-apigatewayv2": "^3.1076.0",
"@aws-sdk/client-appconfig": "^3.1076.0", "@aws-sdk/client-appconfig": "^3.1076.0",
"@aws-sdk/client-auto-scaling": "^3.1146.0",
"@aws-sdk/client-cloudformation": "^3.1111.0", "@aws-sdk/client-cloudformation": "^3.1111.0",
"@aws-sdk/client-cloudfront": "^3.1146.0", "@aws-sdk/client-cloudfront": "^3.1146.0",
"@aws-sdk/client-cloudwatch": "^3.1076.0", "@aws-sdk/client-cloudwatch": "^3.1076.0",
@@ -0,0 +1,160 @@
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})
})
})
@@ -0,0 +1,227 @@
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() : ''
}
+3
View File
@@ -34,6 +34,7 @@ import { LightsailClient } from "@aws-sdk/client-lightsail";
import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider"; import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider";
import { Route53Client } from "@aws-sdk/client-route-53"; import { Route53Client } from "@aws-sdk/client-route-53";
import { CloudFrontClient } from "@aws-sdk/client-cloudfront"; import { CloudFrontClient } from "@aws-sdk/client-cloudfront";
import { AutoScalingClient } from "@aws-sdk/client-auto-scaling";
import { ACMClient } from "@aws-sdk/client-acm"; import { ACMClient } from "@aws-sdk/client-acm";
import { PipesClient } from "@aws-sdk/client-pipes"; import { PipesClient } from "@aws-sdk/client-pipes";
@@ -101,6 +102,7 @@ export type AwsClients = {
cognito: CognitoIdentityProviderClient; cognito: CognitoIdentityProviderClient;
route53: Route53Client; route53: Route53Client;
cloudfront: CloudFrontClient; cloudfront: CloudFrontClient;
autoscaling: AutoScalingClient;
acm: ACMClient; acm: ACMClient;
pipes: PipesClient; pipes: PipesClient;
}; };
@@ -152,6 +154,7 @@ function buildClients(accountId: string): AwsClients {
cognito: new CognitoIdentityProviderClient(base), cognito: new CognitoIdentityProviderClient(base),
route53: new Route53Client(base), route53: new Route53Client(base),
cloudfront: new CloudFrontClient(base), cloudfront: new CloudFrontClient(base),
autoscaling: new AutoScalingClient(base),
acm: new ACMClient(base), acm: new ACMClient(base),
pipes: new PipesClient(base), pipes: new PipesClient(base),
}; };
@@ -0,0 +1,103 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
/** CreateAutoScalingGroup's name rule: 1 to 255 characters, no colon. */
export const AUTO_SCALING_GROUP_NAME_PATTERN = '^[^:]{1,255}$'
/** A capacity the console accepts; AWS's own ceiling is far above what a laptop can run. */
export const AUTO_SCALING_MAX_INSTANCES = 20
const groupColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'capacity', label: 'Instances', path: 'metadata.capacity'},
{name: 'desired', label: 'Desired', path: 'metadata.desiredCapacity'},
{name: 'min', label: 'Min', path: 'metadata.minSize'},
{name: 'max', label: 'Max', path: 'metadata.maxSize'},
{name: 'launchTemplate', label: 'Launch template', path: 'metadata.launchTemplateName', emptyText: '—'},
{name: 'createdAt', label: 'Created', format: 'datetime'},
]
const groupFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
const capacity = {pattern: '^\\d{1,2}$', message: `Use a whole number from 0 to ${AUTO_SCALING_MAX_INSTANCES}`}
export function awsAutoScalingSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'autoscaling',
displayName: 'EC2 Auto Scaling groups',
fields: [
{
name: 'name',
label: 'Name',
type: 'text',
required: true,
validation: {pattern: AUTO_SCALING_GROUP_NAME_PATTERN, message: 'Use up to 255 characters, without a colon'},
},
{
name: 'launchTemplate',
label: 'Launch template',
type: 'text',
required: true,
description: 'The name or ID (lt-…) of an EC2 launch template. New instances use its image and instance type.',
},
{
name: 'launchTemplateVersion',
label: 'Template version',
type: 'text',
required: false,
defaultValue: '$Latest',
description: 'A version number, $Latest or $Default.',
validation: {pattern: '^(\\d+|\\$Latest|\\$Default)$', message: 'Use a version number, $Latest or $Default'},
},
{name: 'minSize', label: 'Minimum', type: 'text', required: true, defaultValue: '1', validation: capacity},
{name: 'desiredCapacity', label: 'Desired', type: 'text', required: false, defaultValue: '1', validation: capacity, description: 'How many instances to run now. Between the minimum and the maximum.'},
{name: 'maxSize', label: 'Maximum', type: 'text', required: true, defaultValue: '2', validation: capacity},
{
name: 'subnets',
label: 'Subnets',
type: 'text',
required: false,
span: true,
description: 'Subnet IDs to launch into, separated by commas. Leave empty for the default subnets.',
validation: {pattern: '^\\s*subnet-[\\w-]+(\\s*,\\s*subnet-[\\w-]+)*\\s*$', message: 'Use subnet IDs separated by commas'},
},
{
name: 'targetGroupArns',
label: 'Target groups',
type: 'textarea',
required: false,
span: true,
description: 'Optional load balancer target group ARNs, one per line. Instances are registered with them as they launch.',
},
{
name: 'healthCheckType',
label: 'Health check',
type: 'select',
required: false,
defaultValue: 'EC2',
options: [
{label: 'EC2 instance status', value: 'EC2'},
{label: 'Load balancer health checks', value: 'ELB'},
],
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List Auto Scaling groups', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create Auto Scaling group', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete group and its instances', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect Auto Scaling group', enabled: true, status: 'available', runtimeRequired: true},
],
autoScalingActions: [
{name: 'setCapacity', label: 'Change minimum, desired and maximum', enabled: true, status: 'available', runtimeRequired: true},
{name: 'terminateInstance', label: 'Terminate an instance', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: groupFilters,
columns: groupColumns,
}
}
export function autoScalingSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsAutoScalingSchema() : null
}
@@ -74,6 +74,13 @@ export const SERVICE_CATALOG = {
order: 20, order: 20,
}, },
serverless: {displayName: 'Serverless', iconKey: 'serverless', group: 'Compute', order: 30}, serverless: {displayName: 'Serverless', iconKey: 'serverless', group: 'Compute', order: 30},
autoscaling: {
displayName: 'Auto Scaling',
displayNameByCloud: {aws: 'EC2 Auto Scaling'},
iconKey: 'autoscaling',
group: 'Compute',
order: 15,
},
containers: { containers: {
displayName: 'Containers', displayName: 'Containers',
displayNameByCloud: {gcp: 'Cloud Run'}, displayNameByCloud: {gcp: 'Cloud Run'},
+12
View File
@@ -186,6 +186,7 @@ export type UserPoolActionName =
| 'describeClient' | 'describeClient'
| 'updateClientOAuth' | 'updateClientOAuth'
export type DistributionActionName = 'setEnabled' | 'createInvalidation' export type DistributionActionName = 'setEnabled' | 'createInvalidation'
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
export type CertificateActionName = 'download' | 'export' | 'revoke' export type CertificateActionName = 'download' | 'export' | 'revoke'
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions' export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
export type HttpApiActionName = export type HttpApiActionName =
@@ -246,6 +247,7 @@ export interface ServiceSchema {
kubernetesActions?: CapabilitySchema<KubernetesActionName>[] kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
userPoolActions?: CapabilitySchema<UserPoolActionName>[] userPoolActions?: CapabilitySchema<UserPoolActionName>[]
distributionActions?: CapabilitySchema<DistributionActionName>[] distributionActions?: CapabilitySchema<DistributionActionName>[]
autoScalingActions?: CapabilitySchema<AutoScalingActionName>[]
certificateActions?: CapabilitySchema<CertificateActionName>[] certificateActions?: CapabilitySchema<CertificateActionName>[]
monitoringActions?: CapabilitySchema<MonitoringActionName>[] monitoringActions?: CapabilitySchema<MonitoringActionName>[]
/** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */ /** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */
@@ -537,6 +539,13 @@ export type CertificateRevocationReason =
| 'CESSATION_OF_OPERATION' | 'CESSATION_OF_OPERATION'
| 'PRIVILEGE_WITHDRAWN' | 'PRIVILEGE_WITHDRAWN'
/** Any of the three may be left out to keep its current value. */
export interface AutoScalingCapacityInput {
minSize?: number
desiredCapacity?: number
maxSize?: number
}
export interface DistributionInvalidation { export interface DistributionInvalidation {
id: string id: string
status: string status: string
@@ -1204,6 +1213,9 @@ export interface CloudServiceAdapter {
setAlarmState?(alarmName: string, state: AlarmStateValue, reason: string): Promise<void> setAlarmState?(alarmName: string, state: AlarmStateValue, reason: string): Promise<void>
setAlarmActionsEnabled?(alarmName: string, enabled: boolean): Promise<void> setAlarmActionsEnabled?(alarmName: string, enabled: boolean): Promise<void>
createDistributionInvalidation?(distributionId: string, paths: string[]): Promise<DistributionInvalidation> createDistributionInvalidation?(distributionId: string, paths: string[]): Promise<DistributionInvalidation>
setAutoScalingCapacity?(groupName: string, input: AutoScalingCapacityInput): Promise<CloudResource>
/** With `replace`, the group launches a new instance in its place; otherwise desired capacity drops by one. */
terminateAutoScalingInstance?(groupName: string, instanceId: string, replace: boolean): Promise<void>
getCertificatePem?(certificateId: string): Promise<CertificatePem> getCertificatePem?(certificateId: string): Promise<CertificatePem>
exportCertificate?(certificateId: string, passphrase: string): Promise<ExportedCertificate> exportCertificate?(certificateId: string, passphrase: string): Promise<ExportedCertificate>
revokeCertificate?(certificateId: string, reason: CertificateRevocationReason): Promise<CloudResource> revokeCertificate?(certificateId: string, reason: CertificateRevocationReason): Promise<CloudResource>
+16 -1
View File
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
import {createCloudAdapterRegistry} from './cloudProxy' import {createCloudAdapterRegistry} from './cloudProxy'
import {checkChildCapabilities} from './cloud-spi/childCapabilities' import {checkChildCapabilities} from './cloud-spi/childCapabilities'
import {isServiceType} from './cloud-spi/serviceCatalog' import {isServiceType} from './cloud-spi/serviceCatalog'
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, CertificateActionName, DatabaseActionName, DistributionActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types' import type {AutoScalingActionName, CloudProvider, CloudServiceAdapter, CloudServiceType, CertificateActionName, DatabaseActionName, DistributionActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
/** /**
* Guards the schema-to-implementation contract for every registered adapter. * Guards the schema-to-implementation contract for every registered adapter.
@@ -81,6 +81,11 @@ const METHOD_FOR_CERTIFICATE_ACTION: Record<CertificateActionName, keyof CloudSe
revoke: 'revokeCertificate', revoke: 'revokeCertificate',
} }
const METHOD_FOR_AUTO_SCALING_ACTION: Record<AutoScalingActionName, keyof CloudServiceAdapter> = {
setCapacity: 'setAutoScalingCapacity',
terminateInstance: 'terminateAutoScalingInstance',
}
const METHOD_FOR_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudServiceAdapter> = { const METHOD_FOR_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudServiceAdapter> = {
updateUser: 'updateUserPoolUser', updateUser: 'updateUserPoolUser',
setUserGroups: 'setUserPoolUserGroups', setUserGroups: 'setUserPoolUserGroups',
@@ -203,6 +208,15 @@ describe('registered adapters honour their schema', () => {
).toBe('function') ).toBe('function')
} }
const autoScalingClaims = (adapter.schema().capabilities?.autoScalingActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of autoScalingClaims) {
const method = METHOD_FOR_AUTO_SCALING_ACTION[capability.name]
expect(
typeof adapter[method],
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
).toBe('function')
}
const distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available') const distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of distributionClaims) { for (const capability of distributionClaims) {
const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name] const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name]
@@ -225,6 +239,7 @@ describe('registered adapters honour their schema', () => {
...(adapter.schema().capabilities?.kubernetesActions ?? []), ...(adapter.schema().capabilities?.kubernetesActions ?? []),
...(adapter.schema().capabilities?.userPoolActions ?? []), ...(adapter.schema().capabilities?.userPoolActions ?? []),
...(adapter.schema().capabilities?.distributionActions ?? []), ...(adapter.schema().capabilities?.distributionActions ?? []),
...(adapter.schema().capabilities?.autoScalingActions ?? []),
...(adapter.schema().capabilities?.certificateActions ?? []), ...(adapter.schema().capabilities?.certificateActions ?? []),
...(adapter.schema().capabilities?.monitoringActions ?? []), ...(adapter.schema().capabilities?.monitoringActions ?? []),
...(adapter.schema().capabilities?.httpApiActions ?? []), ...(adapter.schema().capabilities?.httpApiActions ?? []),
+2
View File
@@ -50,6 +50,7 @@ import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter' import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter' import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter'
import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter' import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter'
import {AwsAutoScalingAdapter} from './adapter-aws/AwsAutoScalingAdapter'
import {AwsCertificatesAdapter} from './adapter-aws/AwsCertificatesAdapter' import {AwsCertificatesAdapter} from './adapter-aws/AwsCertificatesAdapter'
import {AwsPipesAdapter} from './adapter-aws/AwsPipesAdapter' import {AwsPipesAdapter} from './adapter-aws/AwsPipesAdapter'
import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter' import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
@@ -105,6 +106,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsUserPoolsAdapter(clients.cognito), new AwsUserPoolsAdapter(clients.cognito),
new AwsDnsAdapter(clients.route53), new AwsDnsAdapter(clients.route53),
new AwsCdnAdapter(clients.cloudfront, clients.s3), new AwsCdnAdapter(clients.cloudfront, clients.s3),
new AwsAutoScalingAdapter(clients.autoscaling, clients.ec2),
new AwsCertificatesAdapter(clients.acm, resolveAccountId(accountId)), new AwsCertificatesAdapter(clients.acm, resolveAccountId(accountId)),
new AwsPipesAdapter(clients.pipes), new AwsPipesAdapter(clients.pipes),
new AwsMonitoringAdapter(clients.cloudwatch), new AwsMonitoringAdapter(clients.cloudwatch),
+23
View File
@@ -18,6 +18,7 @@ import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-s
import {awsSesEmailSchema} from '../cloud-spi/emailSchema' import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema' import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
import {awsCdnSchema} from '../cloud-spi/cdnSchema' import {awsCdnSchema} from '../cloud-spi/cdnSchema'
import {awsAutoScalingSchema} from '../cloud-spi/autoScalingSchema'
import {awsCertificatesSchema} from '../cloud-spi/certificatesSchema' import {awsCertificatesSchema} from '../cloud-spi/certificatesSchema'
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema' import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema' import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
@@ -852,6 +853,28 @@ describe('cloud schema routes', () => {
expect(calls).toEqual([['pem', 'c1'], ['export', 'c1', 'secret'], ['revoke', 'c1', 'UNSPECIFIED']]) expect(calls).toEqual([['pem', 'c1'], ['export', 'c1', 'secret'], ['revoke', 'c1', 'UNSPECIFIED']])
}) })
test('changes capacity and terminates an instance through the Auto Scaling adapter', async () => {
const calls: unknown[] = []
const app = appWithRoutes([mockAdapter('aws', {
service: 'autoscaling',
schema: awsAutoScalingSchema,
setAutoScalingCapacity: async (id, input) => {
calls.push(['capacity', id, input])
return {id, name: id, cloud: 'aws', service: 'autoscaling', type: 'autoScalingGroup', region: null, createdAt: null, metadata: {desiredCapacity: 3}}
},
terminateAutoScalingInstance: async (id, instanceId, replace) => {
calls.push(['terminate', id, instanceId, replace])
},
})])
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
const base = '/api/clouds/aws/services/autoscaling/resources/web-asg'
const updated = await app.request(`${base}/capacity`, json('PUT', {desiredCapacity: 3}))
expect((await updated.json()).metadata.desiredCapacity).toBe(3)
expect((await app.request(`${base}/instances/i-abc/terminate`, json('POST', {replace: true}))).status).toBe(200)
expect(calls).toEqual([['capacity', 'web-asg', {desiredCapacity: 3}], ['terminate', 'web-asg', 'i-abc', true]])
})
test('clears the SES mailbox through the email adapter', async () => { test('clears the SES mailbox through the email adapter', async () => {
let cleared = false let cleared = false
const app = appWithRoutes([mockAdapter('aws', { const app = appWithRoutes([mockAdapter('aws', {
+19
View File
@@ -2,6 +2,7 @@ import {Hono} from 'hono'
import type {Context} from 'hono' import type {Context} from 'hono'
import type { import type {
AlarmStateValue, AlarmStateValue,
AutoScalingCapacityInput,
CertificateRevocationReason, CertificateRevocationReason,
CloudProvider, CloudProvider,
CloudServiceType, CloudServiceType,
@@ -553,6 +554,24 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
}) })
}) })
app.put('/:cloud/services/autoscaling/resources/:id/capacity', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).setAutoScalingCapacity(cloud, c.req.param('id'), await c.req.json<AutoScalingCapacityInput>())))
})
app.post('/:cloud/services/autoscaling/resources/:id/instances/:instanceId/terminate', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => {
const body = await c.req.json<{replace?: boolean}>()
await svc(c).terminateAutoScalingInstance(cloud, c.req.param('id'), c.req.param('instanceId'), body.replace === true)
return c.json({ok: true})
})
})
app.get('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => { app.get('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
@@ -93,6 +93,7 @@ import type {
UserPoolSignInTestResult, UserPoolSignInTestResult,
UserPoolClientSettings, UserPoolClientSettings,
DistributionInvalidation, DistributionInvalidation,
AutoScalingCapacityInput,
CertificatePem, CertificatePem,
CertificateRevocationReason, CertificateRevocationReason,
ExportedCertificate, ExportedCertificate,
@@ -963,6 +964,18 @@ export class CloudProxyService {
return adapter.revokeCertificate(certificateId, reason) return adapter.revokeCertificate(certificateId, reason)
} }
async setAutoScalingCapacity(cloud: CloudProvider, groupName: string, input: AutoScalingCapacityInput): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'autoscaling')
if (!adapter.setAutoScalingCapacity) throw new NotSupportedError(`Changing capacity is not supported for ${cloud}/autoscaling`)
return adapter.setAutoScalingCapacity(groupName, input)
}
async terminateAutoScalingInstance(cloud: CloudProvider, groupName: string, instanceId: string, replace: boolean): Promise<void> {
const adapter = this.requireAdapter(cloud, 'autoscaling')
if (!adapter.terminateAutoScalingInstance) throw new NotSupportedError(`Terminating instances is not supported for ${cloud}/autoscaling`)
return adapter.terminateAutoScalingInstance(groupName, instanceId, replace)
}
async describeUserPoolClient(cloud: CloudProvider, poolId: string, clientId: string): Promise<UserPoolClientSettings> { async describeUserPoolClient(cloud: CloudProvider, poolId: string, clientId: string): Promise<UserPoolClientSettings> {
const adapter = this.requireAdapter(cloud, 'userpools') const adapter = this.requireAdapter(cloud, 'userpools')
if (!adapter.describeUserPoolClient) throw new NotSupportedError(`App client settings are not supported for ${cloud}/userpools`) if (!adapter.describeUserPoolClient) throw new NotSupportedError(`App client settings are not supported for ${cloud}/userpools`)
+18
View File
@@ -155,6 +155,10 @@ export const apiEndpointKeys = {
export: "clouds.services.certificates.export", export: "clouds.services.certificates.export",
revoke: "clouds.services.certificates.revoke", revoke: "clouds.services.certificates.revoke",
}, },
autoscaling: {
capacity: "clouds.services.autoscaling.capacity",
terminate: "clouds.services.autoscaling.instances.terminate",
},
cdn: { cdn: {
enabled: "clouds.services.cdn.enabled", enabled: "clouds.services.cdn.enabled",
invalidations: "clouds.services.cdn.invalidations", invalidations: "clouds.services.cdn.invalidations",
@@ -952,6 +956,20 @@ export const endpointRegistry: EndpointRegistry = new Map([
method: "POST", method: "POST",
}, },
], ],
[
apiEndpointKeys.clouds.autoscaling.capacity,
{
path: "/clouds/:cloud/services/autoscaling/resources/:id/capacity",
method: "PUT",
},
],
[
apiEndpointKeys.clouds.autoscaling.terminate,
{
path: "/clouds/:cloud/services/autoscaling/resources/:id/instances/:instanceId/terminate",
method: "POST",
},
],
[ [
apiEndpointKeys.clouds.cdn.enabled, apiEndpointKeys.clouds.cdn.enabled,
{ {
@@ -33,6 +33,7 @@ import type {
UserPoolSignInTestResult, UserPoolSignInTestResult,
UserPoolClientSettings, UserPoolClientSettings,
DistributionInvalidation, DistributionInvalidation,
AutoScalingCapacityInput,
CertificatePem, CertificatePem,
CertificateRevocationReason, CertificateRevocationReason,
ExportedCertificate, ExportedCertificate,
@@ -1720,6 +1721,32 @@ export async function revokeCertificate(
return res.data; return res.data;
} }
export async function setAutoScalingCapacity(
cloud: CloudProvider,
groupName: string,
input: AutoScalingCapacityInput,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource, AutoScalingCapacityInput>(
apiEndpointKeys.clouds.autoscaling.capacity,
requestOptions(cloud, "autoscaling", { body: input }),
{ cloud, id: groupName },
);
return res.data;
}
export async function terminateAutoScalingInstance(
cloud: CloudProvider,
groupName: string,
instanceId: string,
replace: boolean,
): Promise<void> {
await apiClient.call<void, { replace: boolean }>(
apiEndpointKeys.clouds.autoscaling.terminate,
requestOptions(cloud, "autoscaling", { body: { replace } }),
{ cloud, id: groupName, instanceId },
);
}
export async function setDistributionEnabled( export async function setDistributionEnabled(
cloud: CloudProvider, cloud: CloudProvider,
distributionId: string, distributionId: string,
@@ -0,0 +1,85 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {beforeEach, expect, test, vi} from 'vitest'
import {AutoScalingPanel} from './AutoScalingPanel'
import type {CloudResource} from '@/types/resource'
const api = vi.hoisted(() => ({
getCloudResource: vi.fn(),
setAutoScalingCapacity: vi.fn(),
terminateAutoScalingInstance: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
function group(metadata: Record<string, unknown> = {}): CloudResource {
return {
id: 'web-asg', name: 'web-asg', cloud: 'aws', service: 'autoscaling', type: 'autoScalingGroup', region: 'us-east-1', createdAt: null, status: null,
metadata: {
minSize: 1, desiredCapacity: 2, maxSize: 3, capacity: '2 in service',
instances: [
{id: 'i-b8736c8b4a8c129a3', availabilityZone: 'us-east-1a', lifecycleState: 'InService', health: 'Healthy', instanceType: 't3.micro'},
{id: 'i-a3155c876867976b1', availabilityZone: 'us-east-1b', lifecycleState: 'InService', health: 'Healthy', instanceType: 't3.micro'},
],
activities: [{id: 'a1', description: 'Launching a new EC2 instance: i-b8736c8b4a8c129a3', cause: 'An instance was started in response to a desired capacity change.', status: 'Successful', statusMessage: null, startTime: null}],
...metadata,
},
}
}
function renderPanel(resource: CloudResource) {
api.getCloudResource.mockResolvedValue(resource)
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={client}><AutoScalingPanel resource={resource} runtimeReachable/></QueryClientProvider>)
}
beforeEach(() => {
vi.clearAllMocks()
api.setAutoScalingCapacity.mockImplementation(async (_cloud, _id, input) => group({desiredCapacity: input.desiredCapacity}))
api.terminateAutoScalingInstance.mockResolvedValue(undefined)
})
test('shows the instances and what launched them', async () => {
renderPanel(group())
expect(await screen.findByText('i-a3155c876867976b1')).toBeInTheDocument()
expect(screen.getByText('Launching a new EC2 instance: i-b8736c8b4a8c129a3')).toBeInTheDocument()
expect(screen.getByText(/never runs them/)).toBeInTheDocument()
})
test('changes the desired capacity', async () => {
const user = userEvent.setup()
renderPanel(group())
const form = await screen.findByRole('form', {name: 'Group capacity'})
const update = within(form).getByRole('button', {name: 'Update capacity'})
expect(update).toBeDisabled()
const desired = within(form).getByLabelText('Desired')
await user.clear(desired)
await user.type(desired, '3')
await user.click(update)
expect(api.setAutoScalingCapacity).toHaveBeenCalledWith('aws', 'web-asg', {minSize: 1, desiredCapacity: 3, maxSize: 3})
expect(await screen.findByText(/Capacity set to 3/)).toBeInTheDocument()
})
test('replaces an instance, or removes one while above the minimum', async () => {
const user = userEvent.setup()
vi.spyOn(window, 'confirm').mockReturnValue(true)
renderPanel(group())
await user.click(await screen.findByRole('button', {name: 'Replace i-b8736c8b4a8c129a3'}))
expect(api.terminateAutoScalingInstance).toHaveBeenCalledWith('aws', 'web-asg', 'i-b8736c8b4a8c129a3', true)
expect(await screen.findByText(/launches a replacement/)).toBeInTheDocument()
await user.click(screen.getByRole('button', {name: 'Remove i-a3155c876867976b1'}))
expect(api.terminateAutoScalingInstance).toHaveBeenLastCalledWith('aws', 'web-asg', 'i-a3155c876867976b1', false)
})
test('cannot remove an instance from a group at its minimum', async () => {
renderPanel(group({minSize: 2}))
expect(await screen.findByRole('button', {name: 'Remove i-b8736c8b4a8c129a3'})).toBeDisabled()
expect(screen.getByRole('button', {name: 'Replace i-b8736c8b4a8c129a3'})).toBeEnabled()
})
@@ -0,0 +1,146 @@
import {RefreshCw} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {getCloudResource, setAutoScalingCapacity, terminateAutoScalingInstance} from '@/api/cloudProxyClient'
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
import {useAccountId} from '@/lib/accountStore'
import {formatDateTime} from '@/lib/format'
import type {CloudResource} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
type Instance = {id: string; availabilityZone: string | null; lifecycleState: string | null; health: string | null; instanceType: string | null}
type Activity = {id: string; description: string; cause: string; status: string | null; statusMessage: string | null; startTime: string | null}
type Capacity = {minSize: string; desiredCapacity: string; maxSize: string}
/** The runtime reconciles every 10 seconds, so a group that is still moving is re-read this often. */
const SETTLING_POLL_MS = 4000
export function AutoScalingPanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [capacity, setCapacity] = useState<Capacity>({minSize: '', desiredCapacity: '', maxSize: ''})
const [notice, setNotice] = useState('')
const groupKey = ['autoscaling-group', accountId, resource?.id]
const groupQuery = useQuery({
queryKey: groupKey,
queryFn: ({signal}) => getCloudResource('aws', 'autoscaling', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
refetchInterval: (query) => query.state.data && !settled(query.state.data) ? SETTLING_POLL_MS : false,
})
const group = groupQuery.data ?? resource
useEffect(() => {
setNotice('')
}, [resource?.id])
// The form follows the group's saved capacity whenever it changes, here or elsewhere.
const savedMin = String(group?.metadata.minSize ?? '')
const savedDesired = String(group?.metadata.desiredCapacity ?? '')
const savedMax = String(group?.metadata.maxSize ?? '')
useEffect(() => {
setCapacity({minSize: savedMin, desiredCapacity: savedDesired, maxSize: savedMax})
}, [group?.id, savedMin, savedDesired, savedMax])
const refresh = () => Promise.all([
queryClient.invalidateQueries({queryKey: groupKey}),
queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources('aws', 'autoscaling')}),
])
const capacityMutation = useMutation({
mutationFn: () => setAutoScalingCapacity('aws', resource?.id ?? '', {
minSize: Number(capacity.minSize),
desiredCapacity: Number(capacity.desiredCapacity),
maxSize: Number(capacity.maxSize),
}),
onSuccess: (updated) => {
setNotice(`Capacity set to ${String(updated.metadata.desiredCapacity)}. TheCloud launches or terminates instances within about 10 seconds.`)
return refresh()
},
})
const terminateMutation = useMutation({
mutationFn: ({instanceId, replace}: {instanceId: string; replace: boolean}) => terminateAutoScalingInstance('aws', resource?.id ?? '', instanceId, replace),
onSuccess: (_result, {instanceId, replace}) => {
setNotice(replace ? `Terminating ${instanceId}. The group launches a replacement to stay at its desired capacity.` : `Terminating ${instanceId} and lowering the desired capacity by one.`)
return refresh()
},
})
if (!resource || !group) {
return <section className="table-panel"><div className="empty compact"><h3>Select an Auto Scaling group</h3><p>Choose a group to change its capacity, see its instances and follow its scaling activity.</p></div></section>
}
const metadata = group.metadata
const instances = Array.isArray(metadata.instances) ? metadata.instances as Instance[] : []
const activities = Array.isArray(metadata.activities) ? metadata.activities as Activity[] : []
const desired = Number(metadata.desiredCapacity ?? 0)
const min = Number(metadata.minSize ?? 0)
const pending = capacityMutation.isPending || terminateMutation.isPending
const disabled = !runtimeReachable || pending
const error = groupQuery.error ?? capacityMutation.error ?? terminateMutation.error
const changed = capacity.minSize !== String(metadata.minSize) || capacity.desiredCapacity !== String(metadata.desiredCapacity) || capacity.maxSize !== String(metadata.maxSize)
const field = (name: keyof Capacity, label: string) => <label className="dynamic-field"><span>{label}</span>
<input className="input" type="number" min={0} max={20} step={1} value={capacity[name]} onChange={(event) => setCapacity({...capacity, [name]: event.target.value})} disabled={disabled} required/>
</label>
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Auto Scaling group ${group.name}`}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Auto Scaling group</p><span className="muted">{String(metadata.capacity ?? '')} · desired {desired}{settled(group) ? '' : ' · scaling'}{group.status ? ` · ${group.status}` : ''}</span></div>
<div className="cdn-actions">
<button className="button" type="button" disabled={!runtimeReachable || groupQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{groupQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
</div>
<form className="dynamic-form autoscaling-capacity" aria-label="Group capacity" onSubmit={(event) => { event.preventDefault(); setNotice(''); capacityMutation.mutate() }}>
{field('minSize', 'Minimum')}
{field('desiredCapacity', 'Desired')}
{field('maxSize', 'Maximum')}
<div><button className="button primary" type="submit" disabled={disabled || !changed}>{capacityMutation.isPending ? 'Updating' : 'Update capacity'}</button></div>
</form>
<p className="muted compact-text autoscaling-note">Scaling policies are not offered: TheCloud stores EC2 scaling policies but never runs them, so set the capacity here. Alarm-driven scaling works for ECS services through Application Auto Scaling.</p>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Instances</p><span className="muted">Each is a real EC2 instance in TheCloud, launched from the group&apos;s template.</span></div></div>
{instances.length === 0 ? <p className="muted compact-text autoscaling-note">No instances. {desired > 0 ? 'The group is launching them.' : 'Raise the desired capacity to launch some.'}</p>
: <div className="cognito-table"><table className="table"><thead><tr><th>Instance</th><th>State</th><th>Health</th><th>Zone</th><th>Type</th><th aria-label="Actions"/></tr></thead><tbody>{instances.map((instance) => (
<tr key={instance.id}>
<td className="mono">{instance.id}</td>
<td><code>{instance.lifecycleState ?? '—'}</code></td>
<td>{instance.health ?? '—'}</td>
<td>{instance.availabilityZone ?? '—'}</td>
<td>{instance.instanceType ?? '—'}</td>
<td className="autoscaling-instance-actions">
<button className="button compact" type="button" disabled={disabled || instance.lifecycleState !== 'InService'} aria-label={`Replace ${instance.id}`} onClick={() => { setNotice(''); terminateMutation.mutate({instanceId: instance.id, replace: true}) }}>Replace</button>
<button className="button compact danger" type="button" disabled={disabled || instance.lifecycleState !== 'InService' || desired - 1 < min} title={desired - 1 < min ? 'The group is at its minimum size' : undefined} aria-label={`Remove ${instance.id}`} onClick={() => {
setNotice('')
if (window.confirm(`Terminate ${instance.id} and lower the desired capacity to ${desired - 1}?`)) terminateMutation.mutate({instanceId: instance.id, replace: false})
}}>Remove</button>
</td>
</tr>
))}</tbody></table></div>}
{activities.length > 0 && <>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Activity</p><span className="muted">The most recent launches and terminations.</span></div></div>
<div className="cognito-table"><table className="table"><thead><tr><th>Started</th><th>Status</th><th>Activity</th></tr></thead><tbody>{activities.map((activity) => (
<tr key={activity.id}>
<td>{formatDateTime(activity.startTime) ?? '—'}</td>
<td><code>{activity.status ?? '—'}</code></td>
<td>{activity.description}<br/><small className="muted">{activity.statusMessage || activity.cause}</small></td>
</tr>
))}</tbody></table></div>
</>}
{metadata.activitiesUnavailable === true && <p className="muted compact-text autoscaling-note">The scaling activity could not be read just now.</p>}
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
</section>
}
/** Settled once every desired instance is in service and none is still starting or stopping. */
function settled(group: CloudResource): boolean {
const instances = Array.isArray(group.metadata.instances) ? group.metadata.instances as Instance[] : []
const inService = instances.filter((instance) => instance.lifecycleState === 'InService').length
return inService === Number(group.metadata.desiredCapacity ?? 0) && instances.every((instance) => instance.lifecycleState === 'InService')
}
@@ -68,6 +68,7 @@ import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPane
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel"; import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel"; import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
import { CloudFrontPanel } from "@/components/CloudFrontPanel"; import { CloudFrontPanel } from "@/components/CloudFrontPanel";
import { AutoScalingPanel } from "@/components/AutoScalingPanel";
import { CertificatePanel } from "@/components/CertificatePanel"; import { CertificatePanel } from "@/components/CertificatePanel";
import { PipePanel } from "@/components/PipePanel"; import { PipePanel } from "@/components/PipePanel";
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel"; import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
@@ -995,6 +996,9 @@ export function DynamicResourceView({
{service === "pipes" && cloud === "aws" && ( {service === "pipes" && cloud === "aws" && (
<PipePanel resource={activeSelected} runtimeReachable={canUseRuntime} /> <PipePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)} )}
{service === "autoscaling" && cloud === "aws" && (
<AutoScalingPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "cdn" && cloud === "aws" && ( {service === "cdn" && cloud === "aws" && (
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} /> <CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)} )}
@@ -16,6 +16,7 @@ import {
MessageSquare, MessageSquare,
Network, Network,
Radio, Radio,
Scaling,
ScrollText, ScrollText,
Scale, Scale,
Server, Server,
@@ -37,6 +38,7 @@ import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy
import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg' import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg'
import route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/Route-53.svg' import route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/Route-53.svg'
import cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Delivery/CloudFront.svg' import cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Delivery/CloudFront.svg'
import autoScalingIcon from '../../../../aws-icons-svg/Compute/EC2-Auto-Scaling.svg'
import certificateManagerIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Certificate-Manager.svg' import certificateManagerIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Certificate-Manager.svg'
import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg' import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg'
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg' import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
@@ -98,6 +100,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
userpools: UsersRound, userpools: UsersRound,
dns: Globe, dns: Globe,
cdn: Earth, cdn: Earth,
autoscaling: Scaling,
certificates: FileBadge, certificates: FileBadge,
monitoring: Activity, monitoring: Activity,
loadbalancing: Scale, loadbalancing: Scale,
@@ -147,6 +150,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
userpools: cognitoIcon, userpools: cognitoIcon,
dns: route53Icon, dns: route53Icon,
cdn: cloudFrontIcon, cdn: cloudFrontIcon,
autoscaling: autoScalingIcon,
certificates: certificateManagerIcon, certificates: certificateManagerIcon,
monitoring: cloudWatchIcon, monitoring: cloudWatchIcon,
workflows: stepFunctionsIcon, workflows: stepFunctionsIcon,
+5
View File
@@ -6625,3 +6625,8 @@ tr[aria-current="true"] td { background: var(--hover-md); }
.pipe-arn, .pipe-code { overflow-wrap: anywhere; } .pipe-arn, .pipe-code { overflow-wrap: anywhere; }
.pipe-code { font-family: var(--mono); font-size: 11px; white-space: pre-wrap; } .pipe-code { font-family: var(--mono); font-size: 11px; white-space: pre-wrap; }
.pipe-role, .pipe-reason { padding: 0 16px 12px; margin: 0; overflow-wrap: anywhere; } .pipe-role, .pipe-reason { padding: 0 16px 12px; margin: 0; overflow-wrap: anywhere; }
/* EC2 Auto Scaling: capacity fields side by side, instance actions in one row. */
.autoscaling-capacity { grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); align-items: end; padding: 4px 16px 12px; }
.autoscaling-note { padding: 0 16px 12px; margin: 0; }
.autoscaling-instance-actions { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; }
+6
View File
@@ -356,6 +356,12 @@ export type CertificateRevocationReason =
| 'CESSATION_OF_OPERATION' | 'CESSATION_OF_OPERATION'
| 'PRIVILEGE_WITHDRAWN' | 'PRIVILEGE_WITHDRAWN'
export interface AutoScalingCapacityInput {
minSize?: number
desiredCapacity?: number
maxSize?: number
}
export interface DistributionInvalidation { export interface DistributionInvalidation {
id: string id: string
status: string status: string
+2
View File
@@ -28,6 +28,7 @@ export type KubernetesActionName =
| 'deleteFargateProfile' | 'deleteFargateProfile'
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query' export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
export type DistributionActionName = 'setEnabled' | 'createInvalidation' export type DistributionActionName = 'setEnabled' | 'createInvalidation'
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
export type CertificateActionName = 'download' | 'export' | 'revoke' export type CertificateActionName = 'download' | 'export' | 'revoke'
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions' export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth' export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth'
@@ -92,6 +93,7 @@ export interface ServiceSchema {
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName> itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>> userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
distributionActions?: Array<CapabilitySchema<DistributionActionName>> distributionActions?: Array<CapabilitySchema<DistributionActionName>>
autoScalingActions?: Array<CapabilitySchema<AutoScalingActionName>>
certificateActions?: Array<CapabilitySchema<CertificateActionName>> certificateActions?: Array<CapabilitySchema<CertificateActionName>>
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>> monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
} }
+18
View File
@@ -22,6 +22,9 @@ importers:
'@aws-sdk/client-appconfig': '@aws-sdk/client-appconfig':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1130.0 version: 3.1130.0
'@aws-sdk/client-auto-scaling':
specifier: ^3.1146.0
version: 3.1156.0
'@aws-sdk/client-cloudformation': '@aws-sdk/client-cloudformation':
specifier: ^3.1111.0 specifier: ^3.1111.0
version: 3.1111.0 version: 3.1111.0
@@ -320,6 +323,10 @@ packages:
resolution: {integrity: sha512-CPVT+sEZLpYYLs6gIUr+O76QWzwjCIMp8ih7VZOQTzUhM8jXlDBwarupCMOkk830STVmKu5zhQjCzYI8i+XU3A==} resolution: {integrity: sha512-CPVT+sEZLpYYLs6gIUr+O76QWzwjCIMp8ih7VZOQTzUhM8jXlDBwarupCMOkk830STVmKu5zhQjCzYI8i+XU3A==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/client-auto-scaling@3.1156.0':
resolution: {integrity: sha512-iv4obhbLigmUcxWnUNd1CJ+jQ8QtCbPg9Ae0dSmv7D1hq/jw50eoMXW8UblNKnOl4FSwWeCtWs4aoUUQYTl7IQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-cloudformation@3.1111.0': '@aws-sdk/client-cloudformation@3.1111.0':
resolution: {integrity: sha512-tZHW6n0vbA1fiJZtxCWVFWUssP47es/EAu7kH2FId6nY/svNoQVHYTqA7ALhLqB+W6ASzNTpnJeCq8evoXlKBQ==} resolution: {integrity: sha512-tZHW6n0vbA1fiJZtxCWVFWUssP47es/EAu7kH2FId6nY/svNoQVHYTqA7ALhLqB+W6ASzNTpnJeCq8evoXlKBQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -2955,6 +2962,17 @@ snapshots:
'@smithy/types': 4.17.2 '@smithy/types': 4.17.2
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/client-auto-scaling@3.1156.0':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/credential-provider-node': 3.972.88
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/client-cloudformation@3.1111.0': '@aws-sdk/client-cloudformation@3.1111.0':
dependencies: dependencies:
'@aws-sdk/core': 3.977.8 '@aws-sdk/core': 3.977.8