From 70cfd4bc962be68b75bf2675eab69cd99cf15374 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Sun, 25 Oct 2026 13:02:49 -0400 Subject: [PATCH] feat(api): add EC2 Auto Scaling groups to the cloud explorer 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. --- README.md | 1 + packages/api/package.json | 1 + .../adapter-aws/AwsAutoScalingAdapter.test.ts | 160 ++++++++++++ .../src/adapter-aws/AwsAutoScalingAdapter.ts | 227 ++++++++++++++++++ packages/api/src/aws.ts | 3 + .../api/src/cloud-spi/autoScalingSchema.ts | 103 ++++++++ packages/api/src/cloud-spi/serviceCatalog.ts | 7 + packages/api/src/cloud-spi/types.ts | 12 + packages/api/src/cloudProxy.test.ts | 17 +- packages/api/src/cloudProxy.ts | 2 + packages/api/src/routes/clouds.test.ts | 23 ++ packages/api/src/routes/clouds.ts | 19 ++ packages/api/src/service/CloudProxyService.ts | 13 + packages/frontend/src/api/api.ts | 18 ++ packages/frontend/src/api/cloudProxyClient.ts | 27 +++ .../src/components/AutoScalingPanel.test.tsx | 85 +++++++ .../src/components/AutoScalingPanel.tsx | 146 +++++++++++ .../src/components/DynamicResourceView.tsx | 4 + .../frontend/src/components/serviceIcons.tsx | 4 + packages/frontend/src/index.css | 5 + packages/frontend/src/types/resource.ts | 6 + packages/frontend/src/types/schema.ts | 2 + pnpm-lock.yaml | 18 ++ 23 files changed, 902 insertions(+), 1 deletion(-) create mode 100644 packages/api/src/adapter-aws/AwsAutoScalingAdapter.test.ts create mode 100644 packages/api/src/adapter-aws/AwsAutoScalingAdapter.ts create mode 100644 packages/api/src/cloud-spi/autoScalingSchema.ts create mode 100644 packages/frontend/src/components/AutoScalingPanel.test.tsx create mode 100644 packages/frontend/src/components/AutoScalingPanel.tsx diff --git a/README.md b/README.md index 9568f25..aa09c3c 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ cd packages/api && bun run scripts/service-matrix.ts | Group | Service | AWS | Azure | GCP | |---|---|---|---|---| | 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 | 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) | diff --git a/packages/api/package.json b/packages/api/package.json index e85a9a6..dbec764 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -14,6 +14,7 @@ "@aws-sdk/client-api-gateway": "^3.1076.0", "@aws-sdk/client-apigatewayv2": "^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-cloudfront": "^3.1146.0", "@aws-sdk/client-cloudwatch": "^3.1076.0", diff --git a/packages/api/src/adapter-aws/AwsAutoScalingAdapter.test.ts b/packages/api/src/adapter-aws/AwsAutoScalingAdapter.test.ts new file mode 100644 index 0000000..ce9854e --- /dev/null +++ b/packages/api/src/adapter-aws/AwsAutoScalingAdapter.test.ts @@ -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 + +function stub(handler: (command: object) => SendResult | Promise) { + 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}) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsAutoScalingAdapter.ts b/packages/api/src/adapter-aws/AwsAutoScalingAdapter.ts new file mode 100644 index 0000000..20c7344 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsAutoScalingAdapter.ts @@ -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 { + 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() : '' +} diff --git a/packages/api/src/aws.ts b/packages/api/src/aws.ts index b1468e9..ced2124 100644 --- a/packages/api/src/aws.ts +++ b/packages/api/src/aws.ts @@ -34,6 +34,7 @@ import { LightsailClient } from "@aws-sdk/client-lightsail"; import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider"; import { Route53Client } from "@aws-sdk/client-route-53"; import { CloudFrontClient } from "@aws-sdk/client-cloudfront"; +import { AutoScalingClient } from "@aws-sdk/client-auto-scaling"; import { ACMClient } from "@aws-sdk/client-acm"; import { PipesClient } from "@aws-sdk/client-pipes"; @@ -101,6 +102,7 @@ export type AwsClients = { cognito: CognitoIdentityProviderClient; route53: Route53Client; cloudfront: CloudFrontClient; + autoscaling: AutoScalingClient; acm: ACMClient; pipes: PipesClient; }; @@ -152,6 +154,7 @@ function buildClients(accountId: string): AwsClients { cognito: new CognitoIdentityProviderClient(base), route53: new Route53Client(base), cloudfront: new CloudFrontClient(base), + autoscaling: new AutoScalingClient(base), acm: new ACMClient(base), pipes: new PipesClient(base), }; diff --git a/packages/api/src/cloud-spi/autoScalingSchema.ts b/packages/api/src/cloud-spi/autoScalingSchema.ts new file mode 100644 index 0000000..b2d82c1 --- /dev/null +++ b/packages/api/src/cloud-spi/autoScalingSchema.ts @@ -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 +} diff --git a/packages/api/src/cloud-spi/serviceCatalog.ts b/packages/api/src/cloud-spi/serviceCatalog.ts index 14fea20..21d5b9a 100644 --- a/packages/api/src/cloud-spi/serviceCatalog.ts +++ b/packages/api/src/cloud-spi/serviceCatalog.ts @@ -74,6 +74,13 @@ export const SERVICE_CATALOG = { order: 20, }, 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: { displayName: 'Containers', displayNameByCloud: {gcp: 'Cloud Run'}, diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index 9fdc786..72208f6 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -186,6 +186,7 @@ export type UserPoolActionName = | 'describeClient' | 'updateClientOAuth' export type DistributionActionName = 'setEnabled' | 'createInvalidation' +export type AutoScalingActionName = 'setCapacity' | 'terminateInstance' export type CertificateActionName = 'download' | 'export' | 'revoke' export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions' export type HttpApiActionName = @@ -246,6 +247,7 @@ export interface ServiceSchema { kubernetesActions?: CapabilitySchema[] userPoolActions?: CapabilitySchema[] distributionActions?: CapabilitySchema[] + autoScalingActions?: CapabilitySchema[] certificateActions?: CapabilitySchema[] monitoringActions?: CapabilitySchema[] /** 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' | '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 { id: string status: string @@ -1204,6 +1213,9 @@ export interface CloudServiceAdapter { setAlarmState?(alarmName: string, state: AlarmStateValue, reason: string): Promise setAlarmActionsEnabled?(alarmName: string, enabled: boolean): Promise createDistributionInvalidation?(distributionId: string, paths: string[]): Promise + setAutoScalingCapacity?(groupName: string, input: AutoScalingCapacityInput): Promise + /** 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 getCertificatePem?(certificateId: string): Promise exportCertificate?(certificateId: string, passphrase: string): Promise revokeCertificate?(certificateId: string, reason: CertificateRevocationReason): Promise diff --git a/packages/api/src/cloudProxy.test.ts b/packages/api/src/cloudProxy.test.ts index 2cacbcf..adc7043 100644 --- a/packages/api/src/cloudProxy.test.ts +++ b/packages/api/src/cloudProxy.test.ts @@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test' import {createCloudAdapterRegistry} from './cloudProxy' import {checkChildCapabilities} from './cloud-spi/childCapabilities' 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. @@ -81,6 +81,11 @@ const METHOD_FOR_CERTIFICATE_ACTION: Record = { + setCapacity: 'setAutoScalingCapacity', + terminateInstance: 'terminateAutoScalingInstance', +} + const METHOD_FOR_USER_POOL_ACTION: Record = { updateUser: 'updateUserPoolUser', setUserGroups: 'setUserPoolUserGroups', @@ -203,6 +208,15 @@ describe('registered adapters honour their schema', () => { ).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') for (const capability of distributionClaims) { 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?.userPoolActions ?? []), ...(adapter.schema().capabilities?.distributionActions ?? []), + ...(adapter.schema().capabilities?.autoScalingActions ?? []), ...(adapter.schema().capabilities?.certificateActions ?? []), ...(adapter.schema().capabilities?.monitoringActions ?? []), ...(adapter.schema().capabilities?.httpApiActions ?? []), diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index 9c616f0..96e85af 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -50,6 +50,7 @@ import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter' import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter' import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter' import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter' +import {AwsAutoScalingAdapter} from './adapter-aws/AwsAutoScalingAdapter' import {AwsCertificatesAdapter} from './adapter-aws/AwsCertificatesAdapter' import {AwsPipesAdapter} from './adapter-aws/AwsPipesAdapter' import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter' @@ -105,6 +106,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap new AwsUserPoolsAdapter(clients.cognito), new AwsDnsAdapter(clients.route53), new AwsCdnAdapter(clients.cloudfront, clients.s3), + new AwsAutoScalingAdapter(clients.autoscaling, clients.ec2), new AwsCertificatesAdapter(clients.acm, resolveAccountId(accountId)), new AwsPipesAdapter(clients.pipes), new AwsMonitoringAdapter(clients.cloudwatch), diff --git a/packages/api/src/routes/clouds.test.ts b/packages/api/src/routes/clouds.test.ts index caa4d5f..636500b 100644 --- a/packages/api/src/routes/clouds.test.ts +++ b/packages/api/src/routes/clouds.test.ts @@ -18,6 +18,7 @@ import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-s import {awsSesEmailSchema} from '../cloud-spi/emailSchema' import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema' import {awsCdnSchema} from '../cloud-spi/cdnSchema' +import {awsAutoScalingSchema} from '../cloud-spi/autoScalingSchema' import {awsCertificatesSchema} from '../cloud-spi/certificatesSchema' import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema' 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']]) }) + 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 () => { let cleared = false const app = appWithRoutes([mockAdapter('aws', { diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index a47d753..6eb6a31 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -2,6 +2,7 @@ import {Hono} from 'hono' import type {Context} from 'hono' import type { AlarmStateValue, + AutoScalingCapacityInput, CertificateRevocationReason, CloudProvider, 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()))) + }) + + 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) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index 9127cba..21ee5a6 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -93,6 +93,7 @@ import type { UserPoolSignInTestResult, UserPoolClientSettings, DistributionInvalidation, + AutoScalingCapacityInput, CertificatePem, CertificateRevocationReason, ExportedCertificate, @@ -963,6 +964,18 @@ export class CloudProxyService { return adapter.revokeCertificate(certificateId, reason) } + async setAutoScalingCapacity(cloud: CloudProvider, groupName: string, input: AutoScalingCapacityInput): Promise { + 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 { + 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 { const adapter = this.requireAdapter(cloud, 'userpools') if (!adapter.describeUserPoolClient) throw new NotSupportedError(`App client settings are not supported for ${cloud}/userpools`) diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index ee0dafc..65678d0 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -155,6 +155,10 @@ export const apiEndpointKeys = { export: "clouds.services.certificates.export", revoke: "clouds.services.certificates.revoke", }, + autoscaling: { + capacity: "clouds.services.autoscaling.capacity", + terminate: "clouds.services.autoscaling.instances.terminate", + }, cdn: { enabled: "clouds.services.cdn.enabled", invalidations: "clouds.services.cdn.invalidations", @@ -952,6 +956,20 @@ export const endpointRegistry: EndpointRegistry = new Map([ 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, { diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 5dad178..178a278 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -33,6 +33,7 @@ import type { UserPoolSignInTestResult, UserPoolClientSettings, DistributionInvalidation, + AutoScalingCapacityInput, CertificatePem, CertificateRevocationReason, ExportedCertificate, @@ -1720,6 +1721,32 @@ export async function revokeCertificate( return res.data; } +export async function setAutoScalingCapacity( + cloud: CloudProvider, + groupName: string, + input: AutoScalingCapacityInput, +): Promise { + const res = await apiClient.call( + 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 { + await apiClient.call( + apiEndpointKeys.clouds.autoscaling.terminate, + requestOptions(cloud, "autoscaling", { body: { replace } }), + { cloud, id: groupName, instanceId }, + ); +} + export async function setDistributionEnabled( cloud: CloudProvider, distributionId: string, diff --git a/packages/frontend/src/components/AutoScalingPanel.test.tsx b/packages/frontend/src/components/AutoScalingPanel.test.tsx new file mode 100644 index 0000000..4dbbd0d --- /dev/null +++ b/packages/frontend/src/components/AutoScalingPanel.test.tsx @@ -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 = {}): 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() +} + +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() +}) diff --git a/packages/frontend/src/components/AutoScalingPanel.tsx b/packages/frontend/src/components/AutoScalingPanel.tsx new file mode 100644 index 0000000..5097eaa --- /dev/null +++ b/packages/frontend/src/components/AutoScalingPanel.tsx @@ -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({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

Select an Auto Scaling group

Choose a group to change its capacity, see its instances and follow its scaling activity.

+ } + + 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) => + + return
+
+

Auto Scaling group

{String(metadata.capacity ?? '')} · desired {desired}{settled(group) ? '' : ' · scaling'}{group.status ? ` · ${group.status}` : ''}
+
+ +
+
+ +
{ event.preventDefault(); setNotice(''); capacityMutation.mutate() }}> + {field('minSize', 'Minimum')} + {field('desiredCapacity', 'Desired')} + {field('maxSize', 'Maximum')} +
+
+

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.

+ +

Instances

Each is a real EC2 instance in TheCloud, launched from the group's template.
+ {instances.length === 0 ?

No instances. {desired > 0 ? 'The group is launching them.' : 'Raise the desired capacity to launch some.'}

+ :
{instances.map((instance) => ( + + + + + + + + + ))}
InstanceStateHealthZoneType
{instance.id}{instance.lifecycleState ?? '—'}{instance.health ?? '—'}{instance.availabilityZone ?? '—'}{instance.instanceType ?? '—'} + + +
} + + {activities.length > 0 && <> +

Activity

The most recent launches and terminations.
+
{activities.map((activity) => ( + + + + + + ))}
StartedStatusActivity
{formatDateTime(activity.startTime) ?? '—'}{activity.status ?? '—'}{activity.description}
{activity.statusMessage || activity.cause}
+ } + {metadata.activitiesUnavailable === true &&

The scaling activity could not be read just now.

} + + {notice &&

{notice}

} + {error instanceof Error &&

{error.message}

} +
+} + +/** 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') +} diff --git a/packages/frontend/src/components/DynamicResourceView.tsx b/packages/frontend/src/components/DynamicResourceView.tsx index d241159..8f2560f 100644 --- a/packages/frontend/src/components/DynamicResourceView.tsx +++ b/packages/frontend/src/components/DynamicResourceView.tsx @@ -68,6 +68,7 @@ import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPane import { CognitoUsersPanel } from "@/components/CognitoUsersPanel"; import { Route53RecordsPanel } from "@/components/Route53RecordsPanel"; import { CloudFrontPanel } from "@/components/CloudFrontPanel"; +import { AutoScalingPanel } from "@/components/AutoScalingPanel"; import { CertificatePanel } from "@/components/CertificatePanel"; import { PipePanel } from "@/components/PipePanel"; import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel"; @@ -995,6 +996,9 @@ export function DynamicResourceView({ {service === "pipes" && cloud === "aws" && ( )} + {service === "autoscaling" && cloud === "aws" && ( + + )} {service === "cdn" && cloud === "aws" && ( )} diff --git a/packages/frontend/src/components/serviceIcons.tsx b/packages/frontend/src/components/serviceIcons.tsx index 7de31a3..e3457f9 100644 --- a/packages/frontend/src/components/serviceIcons.tsx +++ b/packages/frontend/src/components/serviceIcons.tsx @@ -16,6 +16,7 @@ import { MessageSquare, Network, Radio, + Scaling, ScrollText, Scale, 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 route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/Route-53.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 cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg' import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg' @@ -98,6 +100,7 @@ const SERVICE_ICONS: Record = { userpools: UsersRound, dns: Globe, cdn: Earth, + autoscaling: Scaling, certificates: FileBadge, monitoring: Activity, loadbalancing: Scale, @@ -147,6 +150,7 @@ const AWS_SERVICE_ICON_ASSETS: Record = { userpools: cognitoIcon, dns: route53Icon, cdn: cloudFrontIcon, + autoscaling: autoScalingIcon, certificates: certificateManagerIcon, monitoring: cloudWatchIcon, workflows: stepFunctionsIcon, diff --git a/packages/frontend/src/index.css b/packages/frontend/src/index.css index 5a68cd3..2361011 100644 --- a/packages/frontend/src/index.css +++ b/packages/frontend/src/index.css @@ -6625,3 +6625,8 @@ tr[aria-current="true"] td { background: var(--hover-md); } .pipe-arn, .pipe-code { overflow-wrap: anywhere; } .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; } + +/* 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; } diff --git a/packages/frontend/src/types/resource.ts b/packages/frontend/src/types/resource.ts index 9d0febe..a396a30 100644 --- a/packages/frontend/src/types/resource.ts +++ b/packages/frontend/src/types/resource.ts @@ -356,6 +356,12 @@ export type CertificateRevocationReason = | 'CESSATION_OF_OPERATION' | 'PRIVILEGE_WITHDRAWN' +export interface AutoScalingCapacityInput { + minSize?: number + desiredCapacity?: number + maxSize?: number +} + export interface DistributionInvalidation { id: string status: string diff --git a/packages/frontend/src/types/schema.ts b/packages/frontend/src/types/schema.ts index c5e97c0..f8ea8af 100644 --- a/packages/frontend/src/types/schema.ts +++ b/packages/frontend/src/types/schema.ts @@ -28,6 +28,7 @@ export type KubernetesActionName = | 'deleteFargateProfile' export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query' export type DistributionActionName = 'setEnabled' | 'createInvalidation' +export type AutoScalingActionName = 'setCapacity' | 'terminateInstance' export type CertificateActionName = 'download' | 'export' | 'revoke' export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions' export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth' @@ -92,6 +93,7 @@ export interface ServiceSchema { itemActions?: Array | ItemActionName> userPoolActions?: Array> distributionActions?: Array> + autoScalingActions?: Array> certificateActions?: Array> monitoringActions?: Array> } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7432f7d..4533069 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: '@aws-sdk/client-appconfig': specifier: ^3.1076.0 version: 3.1130.0 + '@aws-sdk/client-auto-scaling': + specifier: ^3.1146.0 + version: 3.1156.0 '@aws-sdk/client-cloudformation': specifier: ^3.1111.0 version: 3.1111.0 @@ -320,6 +323,10 @@ packages: resolution: {integrity: sha512-CPVT+sEZLpYYLs6gIUr+O76QWzwjCIMp8ih7VZOQTzUhM8jXlDBwarupCMOkk830STVmKu5zhQjCzYI8i+XU3A==} 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': resolution: {integrity: sha512-tZHW6n0vbA1fiJZtxCWVFWUssP47es/EAu7kH2FId6nY/svNoQVHYTqA7ALhLqB+W6ASzNTpnJeCq8evoXlKBQ==} engines: {node: '>=20.0.0'} @@ -2955,6 +2962,17 @@ snapshots: '@smithy/types': 4.17.2 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': dependencies: '@aws-sdk/core': 3.977.8