Compare commits

...

3 Commits

Author SHA1 Message Date
Zakaria 70cfd4bc96 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.
2026-10-25 13:02:49 -04:00
Zakaria 12b50f4868 feat(api): add EventBridge Pipes to the cloud explorer
CI / checks (pull_request) Successful in 27m2s
Create pipes from SQS, Kinesis, DynamoDB streams or MSK to Lambda, SQS, SNS,
EventBridge buses or Step Functions, with an optional filter, enrichment and
input template; start and stop them, see the flow with links to each resource,
and send a test message to an SQS source. Sources and targets the runtime's
poller cannot deliver between are refused on create.
2026-10-25 13:00:13 -04:00
Zakaria 6e0bb98844 feat(api): add ACM certificates to the cloud explorer
CI / checks (pull_request) Successful in 27m28s
Request public certificates (optionally exportable) or import your own, see
each domain's DNS validation records and what uses a certificate, download the
certificate and chain, export an exportable one with its encrypted key, and
revoke it. ListCertificates asks for every key type, since AWS otherwise
leaves EC certificates out.
2026-10-25 12:41:42 -04:00
33 changed files with 2790 additions and 1 deletions
+3
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) |
@@ -81,6 +82,7 @@ cd packages/api && bun run scripts/service-matrix.ts
| Integration | API Gateway | Yes (list, create, delete, inspect) | No | No | | Integration | API Gateway | Yes (list, create, delete, inspect) | No | No |
| Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No | | Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No |
| Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No | | Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No |
| Integration | EventBridge Pipes / Pipes | Yes (list, create, delete, inspect) | No | No |
| Integration | SES Mailbox / Email | Yes (list, inspect) | No | No | | Integration | SES Mailbox / Email | Yes (list, inspect) | No | No |
| Integration | Cloud Scheduler | No | No | Yes (list, create, delete, inspect) | | Integration | Cloud Scheduler | No | No | Yes (list, create, delete, inspect) |
| Integration | Step Functions / Workflows | Yes (list, create, update, delete, inspect) | No | No | | Integration | Step Functions / Workflows | Yes (list, create, update, delete, inspect) | No | No |
@@ -94,6 +96,7 @@ cd packages/api && bun run scripts/service-matrix.ts
| Security | Secrets Manager / Key Vault / Secret Manager | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | | Security | Secrets Manager / Key Vault / Secret Manager | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
| Security | KMS / Key Management | Yes (list, create, delete, inspect) | No | No | | Security | KMS / Key Management | Yes (list, create, delete, inspect) | No | No |
| Security | Parameter Store | Yes (list, create, delete, inspect) | No | No | | Security | Parameter Store | Yes (list, create, delete, inspect) | No | No |
| Security | Certificate Manager / Certificates | Yes (list, create, delete, inspect) | No | No |
| Observability | CloudWatch / Monitoring | Yes (list, create, delete, inspect) | No | No | | Observability | CloudWatch / Monitoring | Yes (list, create, delete, inspect) | No | No |
| Observability | CloudWatch Logs / Logs | Yes (list, create, delete, inspect) | No | No | | Observability | CloudWatch Logs / Logs | Yes (list, create, delete, inspect) | No | No |
+3
View File
@@ -10,9 +10,11 @@
"test": "FLOCI_UI_ADMIN_USER=admin FLOCI_UI_ADMIN_PASSWORD_HASH='$argon2id$v=19$m=65536,t=2,p=1$I5n/tqYyZO0S+4z++KcAlpAJVu442Ivi7KxPl2CXoO4$mRSyN9R5ACFosgnfnd6S5ePoKrz2XBfVf4DTfG7T6LQ' FLOCI_UI_SESSION_SECRET=01234567890123456789012345678901 FLOCI_UI_SECURE_COOKIES=false bun test" "test": "FLOCI_UI_ADMIN_USER=admin FLOCI_UI_ADMIN_PASSWORD_HASH='$argon2id$v=19$m=65536,t=2,p=1$I5n/tqYyZO0S+4z++KcAlpAJVu442Ivi7KxPl2CXoO4$mRSyN9R5ACFosgnfnd6S5ePoKrz2XBfVf4DTfG7T6LQ' FLOCI_UI_SESSION_SECRET=01234567890123456789012345678901 FLOCI_UI_SECURE_COOKIES=false bun test"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-acm": "^3.1146.0",
"@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",
@@ -36,6 +38,7 @@
"@aws-sdk/client-kms": "^3.1096.0", "@aws-sdk/client-kms": "^3.1096.0",
"@aws-sdk/client-lambda": "^3.1076.0", "@aws-sdk/client-lambda": "^3.1076.0",
"@aws-sdk/client-lightsail": "^3.1076.0", "@aws-sdk/client-lightsail": "^3.1076.0",
"@aws-sdk/client-pipes": "^3.1146.0",
"@aws-sdk/client-rds": "^3.1076.0", "@aws-sdk/client-rds": "^3.1076.0",
"@aws-sdk/client-rds-data": "^3.1076.0", "@aws-sdk/client-rds-data": "^3.1076.0",
"@aws-sdk/client-route-53": "^3.1146.0", "@aws-sdk/client-route-53": "^3.1146.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() : ''
}
@@ -0,0 +1,164 @@
import {describe, expect, test} from 'bun:test'
import {
type ACMClient,
DeleteCertificateCommand,
DescribeCertificateCommand,
ExportCertificateCommand,
GetCertificateCommand,
ImportCertificateCommand,
ListCertificatesCommand,
RequestCertificateCommand,
RevokeCertificateCommand,
} from '@aws-sdk/client-acm'
import {AwsCertificatesAdapter} from './AwsCertificatesAdapter'
import {ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
function stubAcm(handler: (command: object) => SendResult | Promise<SendResult>) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
return handler(command)
},
} as unknown as ACMClient
return {client, sent}
}
function awsError(name: string, status = 400): Error {
return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: status}})
}
const ID = '5ed9dc37-d9fb-456e-911e-e87f091480ba'
const ARN = `arn:aws:acm:us-east-1:000000000000:certificate/${ID}`
const detail = {
CertificateArn: ARN,
DomainName: 'notes.thecloud.localhost',
SubjectAlternativeNames: ['notes.thecloud.localhost', 'api.thecloud.localhost'],
DomainValidationOptions: [{
DomainName: 'notes.thecloud.localhost',
ValidationStatus: 'SUCCESS',
ValidationMethod: 'DNS',
ResourceRecord: {Name: '_a1.notes.thecloud.localhost.', Type: 'CNAME', Value: '_e8.acm-validations.aws.'},
}],
Status: 'ISSUED',
Type: 'AMAZON_ISSUED',
KeyAlgorithm: 'RSA-2048',
Issuer: 'CN=Floci Local CA',
CreatedAt: new Date('2026-10-25T16:28:12Z'),
NotAfter: new Date('2027-10-25T16:28:12Z'),
InUseBy: ['arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1'],
Options: {Export: 'ENABLED', CertificateTransparencyLoggingPreference: 'ENABLED'},
}
function adapterWith(handler: (command: object) => SendResult | Promise<SendResult>) {
const stub = stubAcm(handler)
return {...stub, adapter: new AwsCertificatesAdapter(stub.client, '000000000000', 'us-east-1')}
}
describe('AwsCertificatesAdapter', () => {
test('lists every key type across pages and searches the alternative names too', async () => {
const {adapter, sent} = adapterWith((command) => {
if (!(command instanceof ListCertificatesCommand)) return {}
return command.input.NextToken
? {CertificateSummaryList: [{CertificateArn: ARN.replace(ID, '11111111-2222-3333-4444-555555555555'), DomainName: 'shop.test', Type: 'IMPORTED', Status: 'ISSUED', InUse: false}]}
: {CertificateSummaryList: [{CertificateArn: ARN, DomainName: 'notes.thecloud.localhost', SubjectAlternativeNameSummaries: ['notes.thecloud.localhost', 'api.thecloud.localhost'], Type: 'AMAZON_ISSUED', Status: 'ISSUED', KeyAlgorithm: 'EC-prime256v1', InUse: true}], NextToken: 'page-2'}
})
const all = await adapter.list()
expect(all.map((certificate) => [certificate.id, certificate.name, certificate.metadata.certificateType, certificate.metadata.inUse])).toEqual([
[ID, 'notes.thecloud.localhost', 'Amazon issued', 'Yes'],
['11111111-2222-3333-4444-555555555555', 'shop.test', 'Imported', 'No'],
])
// Without Includes, AWS leaves EC certificates out of the list.
expect((sent[0] as ListCertificatesCommand).input.Includes?.keyTypes).toContain('EC_prime256v1')
expect((await adapter.list({search: 'api.thecloud'})).map((certificate) => certificate.id)).toEqual([ID])
})
test('describes a certificate with its users and the DNS records validation asks for', async () => {
const {adapter, sent} = adapterWith(() => ({Certificate: detail}))
const certificate = await adapter.get(ID)
expect((sent[0] as DescribeCertificateCommand).input.CertificateArn).toBe(ARN)
expect(certificate?.metadata).toMatchObject({
inUse: 'Yes',
inUseBy: detail.InUseBy,
exportable: true,
issuer: 'CN=Floci Local CA',
notAfter: '2027-10-25T16:28:12.000Z',
domainValidations: [{domainName: 'notes.thecloud.localhost', status: 'SUCCESS', method: 'DNS', record: {name: '_a1.notes.thecloud.localhost.', type: 'CNAME', value: '_e8.acm-validations.aws.'}}],
})
})
test('a missing certificate is null, and an id that is not a UUID never reaches ACM', async () => {
const {adapter, sent} = adapterWith(() => { throw awsError('ResourceNotFoundException') })
expect(await adapter.get(ID)).toBeNull()
await expect(adapter.get('../other')).rejects.toBeInstanceOf(ValidationError)
expect(sent).toHaveLength(1)
})
test('requests a certificate for the domain and its extra names', async () => {
const {adapter, sent} = adapterWith((command) => command instanceof RequestCertificateCommand ? {CertificateArn: ARN} : {Certificate: detail})
const created = await adapter.create({values: {source: 'request', domainName: 'Notes.TheCloud.localhost', subjectAlternativeNames: 'api.thecloud.localhost\nnotes.thecloud.localhost\n*.thecloud.localhost', keyAlgorithm: 'EC_prime256v1', export: 'ENABLED'}})
expect(created.id).toBe(ID)
expect((sent[0] as RequestCertificateCommand).input).toEqual({
DomainName: 'notes.thecloud.localhost',
SubjectAlternativeNames: ['notes.thecloud.localhost', 'api.thecloud.localhost', '*.thecloud.localhost'],
ValidationMethod: 'DNS',
KeyAlgorithm: 'EC_prime256v1',
Options: {Export: 'ENABLED'},
})
})
test('refuses a request ACM would reject before calling it', async () => {
const {adapter, sent} = adapterWith(() => ({}))
await expect(adapter.create({values: {source: 'request', domainName: 'not a domain'}})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.create({values: {source: 'request', domainName: 'a.test', subjectAlternativeNames: 'b..test'}})).rejects.toThrow('b..test is not a domain name')
await expect(adapter.create({values: {source: 'request', domainName: 'a.test', keyAlgorithm: 'RSA_8192'}})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.create({values: {source: 'copy', domainName: 'a.test'}})).rejects.toBeInstanceOf(ValidationError)
expect(sent).toHaveLength(0)
})
test('imports PEM text as bytes, with the chain when given', async () => {
const {adapter, sent} = adapterWith((command) => command instanceof ImportCertificateCommand ? {CertificateArn: ARN} : {Certificate: {...detail, Type: 'IMPORTED'}})
const certificate = '-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----'
const privateKey = '-----BEGIN RSA PRIVATE KEY-----\nMIIE\n-----END RSA PRIVATE KEY-----'
const created = await adapter.create({values: {source: 'import', certificate, privateKey, certificateChain: certificate}})
expect(created.metadata.certificateType).toBe('Imported')
const input = (sent[0] as ImportCertificateCommand).input
expect(new TextDecoder().decode(input.Certificate)).toBe(certificate)
expect(new TextDecoder().decode(input.PrivateKey)).toBe(privateKey)
expect(input.CertificateChain).toBeDefined()
await expect(adapter.create({values: {source: 'import', certificate, privateKey: 'secret'}})).rejects.toThrow('privateKey must be PEM text')
})
test('downloads, exports and revokes by the rebuilt ARN', async () => {
const {adapter, sent} = adapterWith((command) => {
if (command instanceof GetCertificateCommand) return {Certificate: 'CERT', CertificateChain: 'CHAIN'}
if (command instanceof ExportCertificateCommand) return {Certificate: 'CERT', CertificateChain: 'CHAIN', PrivateKey: 'ENCRYPTED'}
if (command instanceof RevokeCertificateCommand) return {CertificateArn: ARN}
return {Certificate: {...detail, Status: 'REVOKED', RevocationReason: 'SUPERSEDED'}}
})
expect(await adapter.getCertificatePem(ID)).toEqual({certificate: 'CERT', certificateChain: 'CHAIN'})
expect(await adapter.exportCertificate(ID, 'open-sesame')).toEqual({certificate: 'CERT', certificateChain: 'CHAIN', privateKey: 'ENCRYPTED'})
expect(new TextDecoder().decode((sent[1] as ExportCertificateCommand).input.Passphrase)).toBe('open-sesame')
const revoked = await adapter.revokeCertificate(ID, 'SUPERSEDED')
expect(revoked.status).toBe('REVOKED')
expect((sent[2] as RevokeCertificateCommand).input).toEqual({CertificateArn: ARN, RevocationReason: 'SUPERSEDED'})
await expect(adapter.exportCertificate(ID, 'abc')).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.exportCertificate(ID, 'has$dollar')).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.revokeCertificate(ID, 'BORED' as never)).rejects.toBeInstanceOf(ValidationError)
})
test('deletes by ARN and lets ACM refuse a certificate that is in use', async () => {
const {adapter, sent} = adapterWith(() => { throw awsError('ResourceInUseException') })
await expect(adapter.delete(ID)).rejects.toThrow('ResourceInUseException')
expect((sent[0] as DeleteCertificateCommand).input.CertificateArn).toBe(ARN)
})
})
@@ -0,0 +1,290 @@
import {
type ACMClient,
type CertificateDetail,
type CertificateSummary,
DeleteCertificateCommand,
DescribeCertificateCommand,
ExportCertificateCommand,
GetCertificateCommand,
ImportCertificateCommand,
type KeyAlgorithm,
ListCertificatesCommand,
RequestCertificateCommand,
RevokeCertificateCommand,
} from '@aws-sdk/client-acm'
import {
awsCertificatesSchema,
CERTIFICATE_DOMAIN_PATTERN,
CERTIFICATE_KEY_ALGORITHMS,
CERTIFICATE_REVOCATION_REASONS,
CERTIFICATE_VALIDATION_METHODS,
} from '../cloud-spi/certificatesSchema'
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import type {
CertificatePem,
CertificateRevocationReason,
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
ExportedCertificate,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
/**
* ListCertificates returns only RSA certificates unless asked for other key
* types, so a list without this would silently leave out every EC certificate.
*/
const ALL_KEY_TYPES: KeyAlgorithm[] = ['RSA_1024', 'RSA_2048', 'RSA_3072', 'RSA_4096', 'EC_prime256v1', 'EC_secp384r1', 'EC_secp521r1']
/** ACM's limit on names per certificate, the domain name included. */
const MAX_NAMES = 100
const CERTIFICATE_TYPE_LABEL: Record<string, string> = {AMAZON_ISSUED: 'Amazon issued', IMPORTED: 'Imported', PRIVATE: 'Private'}
/**
* Talks to AWS Certificate Manager, verified against Floci core (community, `latest`).
*
* Notes from probing the runtime:
* - Requested certificates are signed by the runtime's own local CA and issued at
* once (its validation wait defaults to 0), but DescribeCertificate still lists
* the CNAME records DNS validation would ask for, as AWS does.
* - DeleteCertificate refuses with ResourceInUseException while a load balancer,
* distribution or API domain uses the certificate, as on AWS.
*
* Resources are keyed by the certificate's UUID, the last part of its ARN, because
* an ARN's slash cannot travel in a URL path segment; the ARN is rebuilt from the
* account and region this adapter serves.
*/
export class AwsCertificatesAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'certificates' as const
constructor(
private readonly acm: ACMClient,
private readonly accountId: string,
private readonly region = process.env.AWS_REGION || 'us-east-1',
) {}
schema(): ServiceSchema {
return awsCertificatesSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const summaries: CertificateSummary[] = []
let next: string | undefined
do {
const res = await this.acm.send(new ListCertificatesCommand({Includes: {keyTypes: ALL_KEY_TYPES}, MaxItems: 1000, ...(next ? {NextToken: next} : {})}))
summaries.push(...(res.CertificateSummaryList ?? []))
next = res.NextToken
} while (next)
const search = query.search?.trim().toLowerCase()
return summaries.map((summary) => this.fromSummary(summary)).filter((certificate) => !search
|| certificate.name.toLowerCase().includes(search)
|| certificate.id.includes(search)
|| (certificate.metadata.subjectAlternativeNames as string[]).some((name) => name.toLowerCase().includes(search)))
}
async get(id: string): Promise<CloudResource | null> {
try {
const res = await this.acm.send(new DescribeCertificateCommand({CertificateArn: this.arn(id)}))
return res.Certificate ? this.fromDetail(res.Certificate) : null
} catch (error) {
if (errorName(error) === 'ResourceNotFoundException') return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const source = input.values.source ?? 'request'
const arn = source === 'import' ? await this.importCertificate(input.values)
: source === 'request' ? await this.requestCertificate(input.values)
: null
if (arn === null) throw new ValidationError('source must be request or import')
const created = await this.get(idFromArn(arn))
if (!created) throw new RuntimeError(`ACM did not return the new certificate ${arn}`)
return created
}
async delete(id: string): Promise<void> {
await this.acm.send(new DeleteCertificateCommand({CertificateArn: this.arn(id)}))
}
async getCertificatePem(id: string): Promise<CertificatePem> {
const res = await this.acm.send(new GetCertificateCommand({CertificateArn: this.arn(id)}))
if (!res.Certificate) throw new RuntimeError('ACM did not return the certificate')
return {certificate: res.Certificate, certificateChain: res.CertificateChain ?? null}
}
/**
* AWS exports only private certificates and public ones requested as
* exportable; the private key comes back encrypted with the passphrase.
*/
async exportCertificate(id: string, passphrase: string): Promise<ExportedCertificate> {
if (typeof passphrase !== 'string' || passphrase.length < 4 || passphrase.length > 128) {
throw new ValidationError('passphrase must be 4 to 128 characters')
}
if (/[#$%]/.test(passphrase)) throw new ValidationError('passphrase must not contain #, $ or %')
const res = await this.acm.send(new ExportCertificateCommand({CertificateArn: this.arn(id), Passphrase: new TextEncoder().encode(passphrase)}))
if (!res.Certificate || !res.PrivateKey) throw new RuntimeError('ACM did not return the exported certificate')
return {certificate: res.Certificate, certificateChain: res.CertificateChain ?? null, privateKey: res.PrivateKey}
}
async revokeCertificate(id: string, reason: CertificateRevocationReason): Promise<CloudResource> {
if (!(CERTIFICATE_REVOCATION_REASONS as readonly string[]).includes(reason)) {
throw new ValidationError(`reason must be one of ${CERTIFICATE_REVOCATION_REASONS.join(', ')}`)
}
await this.acm.send(new RevokeCertificateCommand({CertificateArn: this.arn(id), RevocationReason: reason}))
const revoked = await this.get(id)
if (!revoked) throw new RuntimeError(`ACM did not return the revoked certificate ${id}`)
return revoked
}
private async requestCertificate(values: Record<string, unknown>): Promise<string> {
const domainName = text(values.domainName).toLowerCase()
if (!domainName) throw new ValidationError('domainName is required')
const pattern = new RegExp(CERTIFICATE_DOMAIN_PATTERN)
if (!pattern.test(domainName)) throw new ValidationError('domainName must be a domain name such as example.com or *.example.com')
const alternatives = [...new Set(text(values.subjectAlternativeNames).split(/[\s,]+/).map((name) => name.toLowerCase()).filter((name) => name && name !== domainName))]
const invalid = alternatives.find((name) => !pattern.test(name))
if (invalid) throw new ValidationError(`${invalid} is not a domain name`)
if (alternatives.length + 1 > MAX_NAMES) throw new ValidationError(`A certificate covers at most ${MAX_NAMES} names`)
const validationMethod = oneOf(values.validationMethod, CERTIFICATE_VALIDATION_METHODS, 'validationMethod')
const keyAlgorithm = oneOf(values.keyAlgorithm, CERTIFICATE_KEY_ALGORITHMS, 'keyAlgorithm')
const exportOption = oneOf(values.export, ['DISABLED', 'ENABLED'] as const, 'export')
const res = await this.acm.send(new RequestCertificateCommand({
DomainName: domainName,
...(alternatives.length > 0 ? {SubjectAlternativeNames: [domainName, ...alternatives]} : {}),
ValidationMethod: validationMethod,
KeyAlgorithm: keyAlgorithm,
Options: {Export: exportOption},
}))
if (!res.CertificateArn) throw new RuntimeError('ACM did not return the requested certificate')
return res.CertificateArn
}
private async importCertificate(values: Record<string, unknown>): Promise<string> {
const certificate = pem(values.certificate, 'certificate', 'CERTIFICATE')
const privateKey = pem(values.privateKey, 'privateKey', 'PRIVATE KEY')
const chain = text(values.certificateChain)
if (chain) pem(chain, 'certificateChain', 'CERTIFICATE')
const encode = (value: string) => new TextEncoder().encode(value)
const res = await this.acm.send(new ImportCertificateCommand({
Certificate: encode(certificate),
PrivateKey: encode(privateKey),
...(chain ? {CertificateChain: encode(chain)} : {}),
}))
if (!res.CertificateArn) throw new RuntimeError('ACM did not return the imported certificate')
return res.CertificateArn
}
private arn(id: string): string {
if (!/^[0-9a-f-]{36}$/i.test(id)) throw new ValidationError('certificate id must be the UUID at the end of its ARN')
return `arn:aws:acm:${this.region}:${this.accountId}:certificate/${id}`
}
private fromSummary(summary: CertificateSummary): CloudResource {
const arn = summary.CertificateArn ?? ''
return {
id: idFromArn(arn),
name: summary.DomainName ?? '',
cloud: 'aws',
service: 'certificates',
type: 'certificate',
region: arn.split(':')[3] || this.region,
createdAt: iso(summary.CreatedAt ?? summary.ImportedAt),
status: summary.Status ?? null,
metadata: {
arn,
certificateType: CERTIFICATE_TYPE_LABEL[summary.Type ?? ''] ?? summary.Type ?? null,
inUse: summary.InUse ? 'Yes' : 'No',
keyAlgorithm: summary.KeyAlgorithm ?? null,
notAfter: iso(summary.NotAfter),
subjectAlternativeNames: summary.SubjectAlternativeNameSummaries ?? [],
},
}
}
private fromDetail(detail: CertificateDetail): CloudResource {
const arn = detail.CertificateArn ?? ''
const inUseBy = detail.InUseBy ?? []
return {
id: idFromArn(arn),
name: detail.DomainName ?? '',
cloud: 'aws',
service: 'certificates',
type: 'certificate',
region: arn.split(':')[3] || this.region,
createdAt: iso(detail.CreatedAt ?? detail.ImportedAt),
status: detail.Status ?? null,
metadata: {
arn,
certificateType: CERTIFICATE_TYPE_LABEL[detail.Type ?? ''] ?? detail.Type ?? null,
inUse: inUseBy.length > 0 ? 'Yes' : 'No',
inUseBy,
keyAlgorithm: detail.KeyAlgorithm ?? null,
signatureAlgorithm: detail.SignatureAlgorithm ?? null,
notBefore: iso(detail.NotBefore),
notAfter: iso(detail.NotAfter),
issuedAt: iso(detail.IssuedAt),
importedAt: iso(detail.ImportedAt),
revokedAt: iso(detail.RevokedAt),
revocationReason: detail.RevocationReason ?? null,
failureReason: detail.FailureReason ?? null,
subject: detail.Subject ?? null,
issuer: detail.Issuer ?? null,
serial: detail.Serial ?? null,
subjectAlternativeNames: detail.SubjectAlternativeNames ?? [],
exportable: detail.Options?.Export === 'ENABLED',
transparencyLogging: detail.Options?.CertificateTransparencyLoggingPreference ?? null,
renewalEligibility: detail.RenewalEligibility ?? null,
domainValidations: (detail.DomainValidationOptions ?? []).map((validation) => ({
domainName: validation.DomainName ?? '',
status: validation.ValidationStatus ?? null,
method: validation.ValidationMethod ?? null,
record: validation.ResourceRecord
? {name: validation.ResourceRecord.Name ?? '', type: validation.ResourceRecord.Type ?? 'CNAME', value: validation.ResourceRecord.Value ?? ''}
: null,
})),
},
}
}
}
/** `arn:aws:acm:us-east-1:000000000000:certificate/<uuid>` — the UUID is what the console keys on. */
function idFromArn(arn: string): string {
return arn.slice(arn.lastIndexOf('/') + 1)
}
function iso(value: Date | undefined): string | null {
return value ? value.toISOString() : null
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function pem(value: unknown, field: string, label: string): string {
const body = text(value)
if (!body) throw new ValidationError(`${field} is required`)
if (!new RegExp(`-----BEGIN [A-Z ]*${label}-----`).test(body)) {
throw new ValidationError(`${field} must be PEM text starting with -----BEGIN ${label}-----`)
}
return body
}
function oneOf<T extends readonly string[]>(value: unknown, allowed: T, field: string): T[number] {
const raw = text(value)
if (!raw) return allowed[0] as T[number]
if (!allowed.includes(raw)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
return raw as T[number]
}
function errorName(error: unknown): string | undefined {
return typeof error === 'object' && error !== null ? (error as {name?: string}).name : undefined
}
@@ -0,0 +1,122 @@
import {describe, expect, test} from 'bun:test'
import {
CreatePipeCommand,
DeletePipeCommand,
DescribePipeCommand,
ListPipesCommand,
type PipesClient,
StartPipeCommand,
StopPipeCommand,
} from '@aws-sdk/client-pipes'
import {AwsPipesAdapter, describeArn} from './AwsPipesAdapter'
import {ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
function stubPipes(handler: (command: object) => SendResult | Promise<SendResult>) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
return handler(command)
},
} as unknown as PipesClient
return {client, sent, adapter: new AwsPipesAdapter(client)}
}
const SRC = 'arn:aws:sqs:us-east-1:000000000000:pipe-src'
const DST = 'arn:aws:lambda:us-east-1:000000000000:function:worker'
const ROLE = 'arn:aws:iam::000000000000:role/pipe-role'
const described = {
Name: 'orders-pipe', Arn: 'arn:aws:pipes:us-east-1:000000000000:pipe/orders-pipe',
DesiredState: 'RUNNING', CurrentState: 'RUNNING', Source: SRC, Target: DST, RoleArn: ROLE,
SourceParameters: {FilterCriteria: {Filters: [{Pattern: '{"body":{"kind":["order"]}}'}]}, SqsQueueParameters: {BatchSize: 5}},
TargetParameters: {InputTemplate: '{"id": <$.messageId>}'},
CreationTime: new Date('2026-10-25T16:42:45Z'),
}
describe('AwsPipesAdapter', () => {
test('lists pipes across pages with readable source and target names', async () => {
const {adapter} = stubPipes((command) => {
if (!(command instanceof ListPipesCommand)) return {}
return command.input.NextToken
? {Pipes: [{Name: 'clicks', CurrentState: 'STOPPED', Source: 'arn:aws:kinesis:us-east-1:000000000000:stream/clicks', Target: 'arn:aws:events:us-east-1:000000000000:event-bus/default'}]}
: {Pipes: [{Name: 'orders-pipe', CurrentState: 'RUNNING', Source: SRC, Target: DST}], NextToken: 'n'}
})
const pipes = await adapter.list()
expect(pipes.map((pipe) => [pipe.name, pipe.status, pipe.metadata.sourceName, pipe.metadata.targetName])).toEqual([
['orders-pipe', 'RUNNING', 'pipe-src (SQS queue)', 'worker (Lambda function)'],
['clicks', 'STOPPED', 'clicks (Kinesis stream)', 'default (EventBridge event bus)'],
])
expect((await adapter.list({search: 'kinesis'})).map((pipe) => pipe.name)).toEqual(['clicks'])
})
test('describes the filter, batch size, template and role', async () => {
const {adapter} = stubPipes(() => described)
expect((await adapter.get('orders-pipe'))?.metadata).toMatchObject({
filterPatterns: ['{"body":{"kind":["order"]}}'],
batchSize: 5,
inputTemplate: '{"id": <$.messageId>}',
roleArn: ROLE,
})
})
test('a missing pipe is null', async () => {
const {adapter} = stubPipes(() => { throw Object.assign(new Error('gone'), {name: 'NotFoundException'}) })
expect(await adapter.get('nope')).toBeNull()
})
test('creates an SQS pipe with its filter, batch size and template', async () => {
const {adapter, sent} = stubPipes((command) => command instanceof DescribePipeCommand ? described : {})
await adapter.create({values: {name: 'orders-pipe', source: SRC, target: DST, roleArn: ROLE, batchSize: '5', filterPattern: '{ "body": { "kind": ["order"] } }', inputTemplate: '{"id": <$.messageId>}'}})
expect((sent[0] as CreatePipeCommand).input).toEqual({
Name: 'orders-pipe',
Source: SRC,
SourceParameters: {FilterCriteria: {Filters: [{Pattern: '{"body":{"kind":["order"]}}'}]}, SqsQueueParameters: {BatchSize: 5}},
Target: DST,
TargetParameters: {InputTemplate: '{"id": <$.messageId>}'},
RoleArn: ROLE,
DesiredState: 'RUNNING',
})
})
test('gives stream and MSK sources the starting position they require', async () => {
const {adapter, sent} = stubPipes((command) => command instanceof DescribePipeCommand ? described : {})
await adapter.create({values: {name: 'clicks', source: 'arn:aws:kinesis:us-east-1:000000000000:stream/clicks', target: DST, roleArn: ROLE, startingPosition: 'TRIM_HORIZON', desiredState: 'STOPPED'}})
expect((sent[0] as CreatePipeCommand).input).toMatchObject({SourceParameters: {KinesisStreamParameters: {StartingPosition: 'TRIM_HORIZON'}}, DesiredState: 'STOPPED'})
await adapter.create({values: {name: 'k', source: 'arn:aws:kafka:us-east-1:000000000000:cluster/events/abc', target: DST, roleArn: ROLE, topicName: 'orders'}})
expect((sent[2] as CreatePipeCommand).input.SourceParameters).toEqual({ManagedStreamingKafkaParameters: {TopicName: 'orders', StartingPosition: 'LATEST'}})
})
test('refuses pipes the runtime could not run, before calling it', async () => {
const {adapter, sent} = stubPipes(() => ({}))
const base = {name: 'p', source: SRC, target: DST, roleArn: ROLE}
await expect(adapter.create({values: {...base, target: 'arn:aws:s3:::bucket'}})).rejects.toThrow('target must be the ARN of a')
await expect(adapter.create({values: {...base, source: 'arn:aws:sns:us-east-1:000000000000:topic'}})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.create({values: {...base, enrichment: 'arn:aws:sqs:us-east-1:000000000000:q'}})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.create({values: {...base, filterPattern: '{kind: order}'}})).rejects.toThrow('filterPattern must be JSON')
await expect(adapter.create({values: {...base, batchSize: '20000'}})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.create({values: {...base, source: 'arn:aws:kafka:us-east-1:000000000000:cluster/c/1'}})).rejects.toThrow('topicName is required')
await expect(adapter.create({values: {...base, roleArn: 'pipe-role'}})).rejects.toBeInstanceOf(ValidationError)
expect(sent).toHaveLength(0)
})
test('starts, stops and deletes by name', async () => {
const {adapter, sent} = stubPipes(() => ({}))
await adapter.stop('orders-pipe')
await adapter.start('orders-pipe')
await adapter.delete('orders-pipe')
expect(sent.map((command) => command.constructor)).toEqual([StopPipeCommand, StartPipeCommand, DeletePipeCommand])
expect(sent.every((command) => (command as StopPipeCommand).input.Name === 'orders-pipe')).toBe(true)
})
test('names every kind of source and target ARN', () => {
expect(describeArn('arn:aws:dynamodb:us-east-1:000000000000:table/orders/stream/2026-10-25T00:00:00.000')).toBe('orders (DynamoDB stream)')
expect(describeArn('arn:aws:states:us-east-1:000000000000:stateMachine:flow')).toBe('flow (Step Functions state machine)')
expect(describeArn('arn:aws:sns:us-east-1:000000000000:alerts')).toBe('alerts (SNS topic)')
})
})
@@ -0,0 +1,237 @@
import {
CreatePipeCommand,
DeletePipeCommand,
DescribePipeCommand,
type DescribePipeCommandOutput,
ListPipesCommand,
type Pipe,
type PipeSourceParameters,
type PipesClient,
StartPipeCommand,
StopPipeCommand,
} from '@aws-sdk/client-pipes'
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import {
awsPipesSchema,
PIPE_ENRICHMENT_SERVICES,
PIPE_NAME_PATTERN,
PIPE_SOURCE_SERVICES,
PIPE_STARTING_POSITIONS,
PIPE_TARGET_SERVICES,
} from '../cloud-spi/pipesSchema'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
const MAX_BATCH_SIZE = 10_000
/**
* Talks to EventBridge Pipes, verified against Floci core (community, `latest`).
*
* Notes from probing the runtime:
* - Pipes really run: the runtime polls SQS, Kinesis, DynamoDB streams and MSK,
* applies the filter, enrichment and input template, and calls the target.
* - Its poller delivers only to Lambda, SQS, SNS, EventBridge buses and Step
* Functions, so create refuses other targets instead of making a pipe that
* would never deliver.
* - ListPipes returns no parameters, so the table shows only what it carries;
* the filter, template and role come from DescribePipe.
*/
export class AwsPipesAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'pipes' as const
constructor(private readonly pipes: PipesClient) {}
schema(): ServiceSchema {
return awsPipesSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const found: Pipe[] = []
let next: string | undefined
do {
const res = await this.pipes.send(new ListPipesCommand({Limit: 100, ...(next ? {NextToken: next} : {})}))
found.push(...(res.Pipes ?? []))
next = res.NextToken
} while (next)
const search = query.search?.trim().toLowerCase()
return found.map((pipe) => toResource(pipe)).filter((pipe) => !search
|| pipe.name.toLowerCase().includes(search)
|| String(pipe.metadata.source).toLowerCase().includes(search)
|| String(pipe.metadata.target).toLowerCase().includes(search))
}
async get(id: string): Promise<CloudResource | null> {
try {
return toResource(await this.pipes.send(new DescribePipeCommand({Name: id})))
} catch (error) {
if (errorName(error) === 'NotFoundException') return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const values = input.values
const name = text(values.name)
if (!new RegExp(PIPE_NAME_PATTERN).test(name)) throw new ValidationError('name must be 1 to 64 letters, numbers, dots, dashes or underscores')
const source = text(values.source)
const sourceService = supported(source, PIPE_SOURCE_SERVICES, 'source')
const target = text(values.target)
supported(target, PIPE_TARGET_SERVICES, 'target')
const enrichment = text(values.enrichment)
if (enrichment) supported(enrichment, PIPE_ENRICHMENT_SERVICES, 'enrichment')
const roleArn = text(values.roleArn)
if (!/^arn:aws[a-z-]*:iam::\d{12}:role\/.+$/.test(roleArn)) throw new ValidationError('roleArn must be an IAM role ARN')
const inputTemplate = text(values.inputTemplate)
const description = text(values.description)
const desiredState = values.desiredState === 'STOPPED' ? 'STOPPED' : 'RUNNING'
await this.pipes.send(new CreatePipeCommand({
Name: name,
Source: source,
SourceParameters: sourceParameters(sourceService, values),
Target: target,
...(inputTemplate ? {TargetParameters: {InputTemplate: inputTemplate}} : {}),
...(enrichment ? {Enrichment: enrichment} : {}),
RoleArn: roleArn,
DesiredState: desiredState,
...(description ? {Description: description} : {}),
}))
const created = await this.get(name)
if (!created) throw new RuntimeError(`EventBridge Pipes did not return the new pipe ${name}`)
return created
}
async delete(id: string): Promise<void> {
await this.pipes.send(new DeletePipeCommand({Name: id}))
}
async start(id: string): Promise<void> {
await this.pipes.send(new StartPipeCommand({Name: id}))
}
async stop(id: string): Promise<void> {
await this.pipes.send(new StopPipeCommand({Name: id}))
}
}
/** Each source kind carries its own parameter block, as CreatePipe expects. */
function sourceParameters(service: keyof typeof PIPE_SOURCE_SERVICES, values: Record<string, unknown>): PipeSourceParameters {
const batchSize = optionalBatchSize(values.batchSize)
const filterPattern = text(values.filterPattern)
const parameters: PipeSourceParameters = filterPattern ? {FilterCriteria: {Filters: [{Pattern: jsonObject(filterPattern, 'filterPattern')}]}} : {}
const batch = batchSize ? {BatchSize: batchSize} : {}
if (service === 'sqs') return {...parameters, SqsQueueParameters: batch}
const startingPosition = text(values.startingPosition) || 'LATEST'
if (!(PIPE_STARTING_POSITIONS as readonly string[]).includes(startingPosition)) {
throw new ValidationError(`startingPosition must be one of ${PIPE_STARTING_POSITIONS.join(', ')}`)
}
const position = startingPosition as (typeof PIPE_STARTING_POSITIONS)[number]
if (service === 'kinesis') return {...parameters, KinesisStreamParameters: {StartingPosition: position, ...batch}}
if (service === 'dynamodb') return {...parameters, DynamoDBStreamParameters: {StartingPosition: position, ...batch}}
const topicName = text(values.topicName)
if (!topicName) throw new ValidationError('topicName is required for an MSK source')
return {...parameters, ManagedStreamingKafkaParameters: {TopicName: topicName, StartingPosition: position, ...batch}}
}
function toResource(pipe: Pipe | DescribePipeCommandOutput): CloudResource {
const name = pipe.Name ?? ''
const detail = 'SourceParameters' in pipe ? pipe : undefined
const sourceParameters = detail?.SourceParameters
const batchSize = sourceParameters?.SqsQueueParameters?.BatchSize
?? sourceParameters?.KinesisStreamParameters?.BatchSize
?? sourceParameters?.DynamoDBStreamParameters?.BatchSize
?? sourceParameters?.ManagedStreamingKafkaParameters?.BatchSize
return {
id: name,
name,
cloud: 'aws',
service: 'pipes',
type: 'pipe',
region: pipe.Arn?.split(':')[3] || null,
createdAt: pipe.CreationTime ? pipe.CreationTime.toISOString() : null,
status: pipe.CurrentState ?? null,
metadata: {
arn: pipe.Arn,
desiredState: pipe.DesiredState ?? null,
currentState: pipe.CurrentState ?? null,
stateReason: pipe.StateReason ?? null,
source: pipe.Source ?? null,
sourceName: describeArn(pipe.Source),
target: pipe.Target ?? null,
targetName: describeArn(pipe.Target),
enrichment: pipe.Enrichment ?? null,
lastModified: pipe.LastModifiedTime ? pipe.LastModifiedTime.toISOString() : null,
...(detail ? {
description: detail.Description ?? null,
roleArn: detail.RoleArn ?? null,
filterPatterns: (sourceParameters?.FilterCriteria?.Filters ?? []).map((filter) => filter.Pattern ?? '').filter(Boolean),
batchSize: batchSize ?? null,
startingPosition: sourceParameters?.KinesisStreamParameters?.StartingPosition
?? sourceParameters?.DynamoDBStreamParameters?.StartingPosition
?? sourceParameters?.ManagedStreamingKafkaParameters?.StartingPosition
?? null,
topicName: sourceParameters?.ManagedStreamingKafkaParameters?.TopicName ?? null,
inputTemplate: detail.TargetParameters?.InputTemplate ?? null,
enrichmentInputTemplate: detail.EnrichmentParameters?.InputTemplate ?? null,
} : {}),
},
}
}
/**
* A readable name for an ARN: `queue (SQS queue)`. The resource part differs by
* service — `function:name`, `stream/name`, `table/name/stream/…`, `cluster/name/uuid`.
*/
export function describeArn(arn: string | undefined): string | null {
if (!arn) return null
const parts = arn.split(':')
const service = parts[2] ?? ''
const resource = parts.slice(5).join(':')
const name = service === 'lambda' || service === 'states' ? resource.split(':')[1] ?? resource
: service === 'dynamodb' || service === 'kafka' || service === 'kinesis' || service === 'events' ? resource.split('/')[1] ?? resource
: resource
const kind = (PIPE_SOURCE_SERVICES as Record<string, string>)[service] ?? (PIPE_TARGET_SERVICES as Record<string, string>)[service]
return kind ? `${name} (${kind})` : name
}
function supported<T extends Record<string, string>>(arn: string, services: T, field: string): keyof T {
const service = arn.split(':')[2] ?? ''
if (!arn.startsWith('arn:') || !(service in services)) {
throw new ValidationError(`${field} must be the ARN of a ${Object.values(services).join(', ')}`)
}
return service as keyof T
}
function optionalBatchSize(value: unknown): number | undefined {
const raw = text(value)
if (!raw) return undefined
const size = Number(raw)
if (!Number.isInteger(size) || size < 1 || size > MAX_BATCH_SIZE) throw new ValidationError(`batchSize must be a whole number from 1 to ${MAX_BATCH_SIZE}`)
return size
}
function jsonObject(value: string, field: string): string {
let parsed: unknown
try {
parsed = JSON.parse(value)
} catch {
throw new ValidationError(`${field} must be JSON, such as {"body": {"kind": ["order"]}}`)
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw new ValidationError(`${field} must be a JSON object`)
return JSON.stringify(parsed)
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function errorName(error: unknown): string | undefined {
return typeof error === 'object' && error !== null ? (error as {name?: string}).name : undefined
}
+9
View File
@@ -34,6 +34,9 @@ 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 { PipesClient } from "@aws-sdk/client-pipes";
const endpoint = process.env.FLOCI_ENDPOINT; const endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1"; const region = process.env.AWS_REGION || "us-east-1";
@@ -99,6 +102,9 @@ export type AwsClients = {
cognito: CognitoIdentityProviderClient; cognito: CognitoIdentityProviderClient;
route53: Route53Client; route53: Route53Client;
cloudfront: CloudFrontClient; cloudfront: CloudFrontClient;
autoscaling: AutoScalingClient;
acm: ACMClient;
pipes: PipesClient;
}; };
export type AwsClientName = keyof AwsClients; export type AwsClientName = keyof AwsClients;
@@ -148,6 +154,9 @@ 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),
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
}
@@ -0,0 +1,154 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
/** A domain name, optionally a wildcard (`*.example.com`), as RequestCertificate accepts it. */
export const CERTIFICATE_DOMAIN_PATTERN = '^(\\*\\.)?(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$'
/** The key algorithms RequestCertificate accepts for a public certificate. */
export const CERTIFICATE_KEY_ALGORITHMS = ['RSA_2048', 'EC_prime256v1', 'EC_secp384r1'] as const
export const CERTIFICATE_VALIDATION_METHODS = ['DNS', 'EMAIL'] as const
/** RevokeCertificate's reasons, as the API names them. */
export const CERTIFICATE_REVOCATION_REASONS = [
'UNSPECIFIED',
'KEY_COMPROMISE',
'CA_COMPROMISE',
'AFFILIATION_CHANGED',
'SUPERSEDED',
'CESSATION_OF_OPERATION',
'PRIVILEGE_WITHDRAWN',
] as const
const certificateColumns: TableColumnSchema[] = [
{name: 'name', label: 'Domain name'},
{name: 'status', label: 'Status', format: 'badge'},
{name: 'certificateType', label: 'Type', path: 'metadata.certificateType'},
{name: 'inUse', label: 'In use', path: 'metadata.inUse'},
{name: 'keyAlgorithm', label: 'Key algorithm', path: 'metadata.keyAlgorithm'},
{name: 'notAfter', label: 'Expires', path: 'metadata.notAfter', format: 'datetime', emptyText: '—'},
{name: 'id', label: 'Certificate ID', format: 'code'},
]
const certificateFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
const requested = {field: 'source', values: ['request']}
const imported = {field: 'source', values: ['import']}
export function awsCertificatesSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'certificates',
displayName: 'AWS Certificate Manager',
fields: [
{
name: 'source',
label: 'Certificate type',
type: 'select',
required: true,
defaultValue: 'request',
options: [
{label: 'Request a public certificate', value: 'request'},
{label: 'Import a certificate', value: 'import'},
],
span: true,
},
{
name: 'domainName',
label: 'Domain name',
type: 'text',
required: false,
requiredWhen: {field: 'source', equals: 'request'},
visibleWhen: requested,
description: 'For example notes.thecloud.localhost, or *.thecloud.localhost for every subdomain. TheCloud signs it with its own local certificate authority.',
validation: {pattern: CERTIFICATE_DOMAIN_PATTERN, message: 'Use a domain name such as example.com or *.example.com'},
},
{
name: 'subjectAlternativeNames',
label: 'Additional names',
type: 'textarea',
required: false,
visibleWhen: requested,
description: 'Other names the certificate covers, one per line.',
},
{
name: 'validationMethod',
label: 'Validation method',
type: 'select',
required: false,
defaultValue: 'DNS',
visibleWhen: requested,
description: 'How ACM proves you own the domain. TheCloud issues the certificate without checking, and still shows the DNS records AWS would ask for.',
options: CERTIFICATE_VALIDATION_METHODS.map((value) => ({label: value, value})),
},
{
name: 'keyAlgorithm',
label: 'Key algorithm',
type: 'select',
required: false,
defaultValue: 'RSA_2048',
visibleWhen: requested,
options: CERTIFICATE_KEY_ALGORITHMS.map((value) => ({label: value.replace('_', ' '), value})),
},
{
name: 'export',
label: 'Exportable',
type: 'select',
required: false,
defaultValue: 'DISABLED',
visibleWhen: requested,
description: 'An exportable certificate can be downloaded with its private key, to use on servers outside AWS. This is chosen once and cannot be changed later.',
options: [
{label: 'No', value: 'DISABLED'},
{label: 'Yes, allow export', value: 'ENABLED'},
],
},
{
name: 'certificate',
label: 'Certificate body (PEM)',
type: 'textarea',
required: false,
requiredWhen: {field: 'source', equals: 'import'},
visibleWhen: imported,
span: true,
description: 'Starts with -----BEGIN CERTIFICATE-----.',
},
{
name: 'privateKey',
label: 'Private key (PEM)',
type: 'textarea',
required: false,
requiredWhen: {field: 'source', equals: 'import'},
visibleWhen: imported,
span: true,
description: 'The unencrypted key that matches the certificate.',
},
{
name: 'certificateChain',
label: 'Certificate chain (PEM)',
type: 'textarea',
required: false,
visibleWhen: imported,
span: true,
description: 'The intermediate certificates, if the certificate is not self-signed.',
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List certificates', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Request or import a certificate', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete certificate', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect certificate', enabled: true, status: 'available', runtimeRequired: true},
],
certificateActions: [
{name: 'download', label: 'Download the certificate and chain', enabled: true, status: 'available', runtimeRequired: true},
{name: 'export', label: 'Export with the private key', enabled: true, status: 'available', runtimeRequired: true},
{name: 'revoke', label: 'Revoke certificate', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: certificateFilters,
columns: certificateColumns,
}
}
export function certificatesSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsCertificatesSchema() : null
}
+151
View File
@@ -0,0 +1,151 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
/** CreatePipe's name rule. */
export const PIPE_NAME_PATTERN = '^[\\.\\-_A-Za-z0-9]{1,64}$'
export const PIPE_STARTING_POSITIONS = ['TRIM_HORIZON', 'LATEST'] as const
/**
* The sources and targets the runtime's pipe poller actually moves events
* between, keyed by the ARN's service field. Anything else is refused on create
* rather than accepted into a pipe that would never deliver.
*/
export const PIPE_SOURCE_SERVICES = {sqs: 'SQS queue', kinesis: 'Kinesis stream', dynamodb: 'DynamoDB stream', kafka: 'MSK topic'} as const
export const PIPE_TARGET_SERVICES = {lambda: 'Lambda function', sqs: 'SQS queue', sns: 'SNS topic', events: 'EventBridge event bus', states: 'Step Functions state machine'} as const
export const PIPE_ENRICHMENT_SERVICES = {lambda: 'Lambda function', states: 'Step Functions state machine'} as const
const ARN = '^arn:aws[a-z-]*:[a-z0-9-]+:[a-z0-9-]*:\\d{12}:.+$'
const pipeColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'status', label: 'State', format: 'badge'},
{name: 'source', label: 'Source', path: 'metadata.sourceName'},
{name: 'target', label: 'Target', path: 'metadata.targetName'},
{name: 'createdAt', label: 'Created', format: 'datetime'},
]
const pipeFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
export function awsPipesSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'pipes',
displayName: 'EventBridge Pipes',
fields: [
{
name: 'name',
label: 'Name',
type: 'text',
required: true,
validation: {pattern: PIPE_NAME_PATTERN, message: 'Use up to 64 letters, numbers, dots, dashes and underscores'},
},
{name: 'description', label: 'Description', type: 'text', required: false, validation: {maxLength: 512}},
{
name: 'source',
label: 'Source ARN',
type: 'text',
required: true,
span: true,
description: 'Where events come from: an SQS queue, a Kinesis stream, a DynamoDB stream or an MSK cluster.',
validation: {pattern: ARN, message: 'Use the ARN of a queue or stream'},
},
{
name: 'startingPosition',
label: 'Starting position',
type: 'select',
required: false,
defaultValue: 'LATEST',
description: 'For streams and MSK: read new records only, or everything still in the stream.',
options: [
{label: 'New records only (LATEST)', value: 'LATEST'},
{label: 'Oldest record (TRIM_HORIZON)', value: 'TRIM_HORIZON'},
],
},
{
name: 'topicName',
label: 'MSK topic',
type: 'text',
required: false,
description: 'Required when the source is an MSK cluster.',
},
{
name: 'batchSize',
label: 'Batch size',
type: 'text',
required: false,
description: 'Records sent to the target at once. 1 to 10,000; leave empty for the default of 10.',
validation: {pattern: '^[1-9][0-9]{0,4}$', message: 'Use a whole number from 1 to 10000'},
},
{
name: 'filterPattern',
label: 'Filter pattern',
type: 'textarea',
required: false,
span: true,
description: 'Optional EventBridge event pattern; only matching records go on. For SQS, match the parsed message under body, for example {"body": {"kind": ["order"]}}.',
},
{
name: 'enrichment',
label: 'Enrichment ARN',
type: 'text',
required: false,
span: true,
description: 'Optional Lambda function or Step Functions state machine that transforms each batch before the target gets it.',
validation: {pattern: ARN, message: 'Use the ARN of a Lambda function or state machine'},
},
{
name: 'target',
label: 'Target ARN',
type: 'text',
required: true,
span: true,
description: 'Where events go: a Lambda function, SQS queue, SNS topic, EventBridge event bus or Step Functions state machine.',
validation: {pattern: ARN, message: 'Use the ARN of the target'},
},
{
name: 'inputTemplate',
label: 'Target input template',
type: 'textarea',
required: false,
span: true,
description: 'Optional. Reshape what the target receives, with <$.path> placeholders, for example {"id": <$.messageId>}.',
},
{
name: 'roleArn',
label: 'Execution role ARN',
type: 'text',
required: true,
span: true,
description: 'The IAM role the pipe uses to read the source and call the target, as AWS requires. Create one in IAM first.',
validation: {pattern: '^arn:aws[a-z-]*:iam::\\d{12}:role/.+$', message: 'Use an IAM role ARN such as arn:aws:iam::000000000000:role/pipe-role'},
},
{
name: 'desiredState',
label: 'State after creating',
type: 'select',
required: false,
defaultValue: 'RUNNING',
options: [
{label: 'Running', value: 'RUNNING'},
{label: 'Stopped', value: 'STOPPED'},
],
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List pipes', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create pipe', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete pipe', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect pipe', enabled: true, status: 'available', runtimeRequired: true},
{name: 'start', label: 'Start pipe', enabled: true, status: 'available', runtimeRequired: true},
{name: 'stop', label: 'Stop pipe', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: pipeFilters,
columns: pipeColumns,
}
}
export function pipesSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsPipesSchema() : 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'},
@@ -239,6 +246,13 @@ export const SERVICE_CATALOG = {
// AWS retains its dedicated page while Azure and GCP use Cloud Explorer. // AWS retains its dedicated page while Azure and GCP use Cloud Explorer.
routeByCloud: {aws: '/secretsmanager'}, routeByCloud: {aws: '/secretsmanager'},
}, },
certificates: {
displayName: 'Certificates',
displayNameByCloud: {aws: 'Certificate Manager'},
iconKey: 'certificates',
group: 'Security',
order: 35,
},
cdn: { cdn: {
displayName: 'CDN', displayName: 'CDN',
displayNameByCloud: {aws: 'CloudFront'}, displayNameByCloud: {aws: 'CloudFront'},
@@ -274,6 +288,13 @@ export const SERVICE_CATALOG = {
group: 'Provisioning', group: 'Provisioning',
order: 20, order: 20,
}, },
pipes: {
displayName: 'Pipes',
displayNameByCloud: {aws: 'EventBridge Pipes'},
iconKey: 'pipes',
group: 'Integration',
order: 25,
},
scheduler: { scheduler: {
displayName: 'Cloud Scheduler', displayName: 'Cloud Scheduler',
displayNameByCloud: {gcp: 'Cloud Scheduler'}, displayNameByCloud: {gcp: 'Cloud Scheduler'},
+37
View File
@@ -186,6 +186,8 @@ 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 MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions' export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
export type HttpApiActionName = export type HttpApiActionName =
| 'describe' | 'describe'
@@ -245,6 +247,8 @@ 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>[]
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. */
httpApiActions?: CapabilitySchema<HttpApiActionName>[] httpApiActions?: CapabilitySchema<HttpApiActionName>[]
@@ -515,6 +519,33 @@ export interface AlarmHistoryItem {
summary: string summary: string
} }
/** A certificate and its chain as PEM text; never the private key. */
export interface CertificatePem {
certificate: string
certificateChain: string | null
}
/** An exported certificate: the private key is PEM, encrypted with the caller's passphrase. */
export interface ExportedCertificate extends CertificatePem {
privateKey: string
}
export type CertificateRevocationReason =
| 'UNSPECIFIED'
| 'KEY_COMPROMISE'
| 'CA_COMPROMISE'
| 'AFFILIATION_CHANGED'
| 'SUPERSEDED'
| '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 { export interface DistributionInvalidation {
id: string id: string
status: string status: string
@@ -1182,6 +1213,12 @@ 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>
exportCertificate?(certificateId: string, passphrase: string): Promise<ExportedCertificate>
revokeCertificate?(certificateId: string, reason: CertificateRevocationReason): Promise<CloudResource>
updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings> updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings>
/** Clears the provider's locally captured email inbox, if it exposes one. */ /** Clears the provider's locally captured email inbox, if it exposes one. */
clearEmailInbox?(): Promise<void> clearEmailInbox?(): Promise<void>
+32 -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, 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.
@@ -75,6 +75,17 @@ const METHOD_FOR_DISTRIBUTION_ACTION: Record<DistributionActionName, keyof Cloud
createInvalidation: 'createDistributionInvalidation', createInvalidation: 'createDistributionInvalidation',
} }
const METHOD_FOR_CERTIFICATE_ACTION: Record<CertificateActionName, keyof CloudServiceAdapter> = {
download: 'getCertificatePem',
export: 'exportCertificate',
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',
@@ -188,6 +199,24 @@ describe('registered adapters honour their schema', () => {
).toBe('function') ).toBe('function')
} }
const certificateClaims = (adapter.schema().capabilities?.certificateActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of certificateClaims) {
const method = METHOD_FOR_CERTIFICATE_ACTION[capability.name]
expect(
typeof adapter[method],
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
).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]
@@ -210,6 +239,8 @@ 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?.monitoringActions ?? []), ...(adapter.schema().capabilities?.monitoringActions ?? []),
...(adapter.schema().capabilities?.httpApiActions ?? []), ...(adapter.schema().capabilities?.httpApiActions ?? []),
...(adapter.schema().capabilities?.collectionActions ?? []), ...(adapter.schema().capabilities?.collectionActions ?? []),
+6
View File
@@ -50,6 +50,9 @@ 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 {AwsPipesAdapter} from './adapter-aws/AwsPipesAdapter'
import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter' import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws' import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2' import {createEc2Service} from './services/ec2'
@@ -103,6 +106,9 @@ 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 AwsPipesAdapter(clients.pipes),
new AwsMonitoringAdapter(clients.cloudwatch), new AwsMonitoringAdapter(clients.cloudwatch),
new AzureStorageAdapter(), new AzureStorageAdapter(),
new AzureServiceBusAdapter(), new AzureServiceBusAdapter(),
+52
View File
@@ -18,6 +18,8 @@ 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 {awsMonitoringSchema} from '../cloud-spi/monitoringSchema' import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema' import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema' import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
@@ -823,6 +825,56 @@ describe('cloud schema routes', () => {
expect(calls).toEqual([['enabled', 'E123', false], ['invalidate', 'E123', ['/*']]]) expect(calls).toEqual([['enabled', 'E123', false], ['invalidate', 'E123', ['/*']]])
}) })
test('downloads, exports and revokes a certificate through the ACM adapter', async () => {
const calls: unknown[] = []
const app = appWithRoutes([mockAdapter('aws', {
service: 'certificates',
schema: awsCertificatesSchema,
getCertificatePem: async (id) => {
calls.push(['pem', id])
return {certificate: 'CERT', certificateChain: null}
},
exportCertificate: async (id, passphrase) => {
calls.push(['export', id, passphrase])
return {certificate: 'CERT', certificateChain: null, privateKey: 'KEY'}
},
revokeCertificate: async (id, reason) => {
calls.push(['revoke', id, reason])
return {id, name: 'a.test', cloud: 'aws', service: 'certificates', type: 'certificate', region: null, createdAt: null, status: 'REVOKED', metadata: {}}
},
})])
const json = (body: unknown) => ({method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
const base = '/api/clouds/aws/services/certificates/resources/c1'
expect(await (await app.request(`${base}/pem`)).json()).toEqual({certificate: 'CERT', certificateChain: null})
expect((await (await app.request(`${base}/export`, json({passphrase: 'secret'}))).json()).privateKey).toBe('KEY')
const revoked = await app.request(`${base}/revoke`, json({}))
expect((await revoked.json()).status).toBe('REVOKED')
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', {
+47
View File
@@ -2,6 +2,8 @@ import {Hono} from 'hono'
import type {Context} from 'hono' import type {Context} from 'hono'
import type { import type {
AlarmStateValue, AlarmStateValue,
AutoScalingCapacityInput,
CertificateRevocationReason,
CloudProvider, CloudProvider,
CloudServiceType, CloudServiceType,
CreateDatabaseSnapshotInput, CreateDatabaseSnapshotInput,
@@ -525,6 +527,51 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
}) })
}) })
app.get('/:cloud/services/certificates/resources/:id/pem', 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).getCertificatePem(cloud, c.req.param('id'))))
})
app.post('/:cloud/services/certificates/resources/:id/export', 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<{passphrase?: string}>()
return c.json(await svc(c).exportCertificate(cloud, c.req.param('id'), body.passphrase as string))
})
})
app.post('/:cloud/services/certificates/resources/:id/revoke', 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<{reason?: CertificateRevocationReason}>()
return c.json(await svc(c).revokeCertificate(cloud, c.req.param('id'), body.reason ?? 'UNSPECIFIED'))
})
})
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,10 @@ import type {
UserPoolSignInTestResult, UserPoolSignInTestResult,
UserPoolClientSettings, UserPoolClientSettings,
DistributionInvalidation, DistributionInvalidation,
AutoScalingCapacityInput,
CertificatePem,
CertificateRevocationReason,
ExportedCertificate,
AlarmHistoryItem, AlarmHistoryItem,
AlarmStateValue, AlarmStateValue,
MetricSeries, MetricSeries,
@@ -942,6 +946,36 @@ export class CloudProxyService {
return adapter.createDistributionInvalidation(distributionId, paths) return adapter.createDistributionInvalidation(distributionId, paths)
} }
async getCertificatePem(cloud: CloudProvider, certificateId: string): Promise<CertificatePem> {
const adapter = this.requireAdapter(cloud, 'certificates')
if (!adapter.getCertificatePem) throw new NotSupportedError(`Downloading certificates is not supported for ${cloud}/certificates`)
return adapter.getCertificatePem(certificateId)
}
async exportCertificate(cloud: CloudProvider, certificateId: string, passphrase: string): Promise<ExportedCertificate> {
const adapter = this.requireAdapter(cloud, 'certificates')
if (!adapter.exportCertificate) throw new NotSupportedError(`Exporting certificates is not supported for ${cloud}/certificates`)
return adapter.exportCertificate(certificateId, passphrase)
}
async revokeCertificate(cloud: CloudProvider, certificateId: string, reason: CertificateRevocationReason): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'certificates')
if (!adapter.revokeCertificate) throw new NotSupportedError(`Revoking certificates is not supported for ${cloud}/certificates`)
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`)
+44
View File
@@ -150,6 +150,15 @@ export const apiEndpointKeys = {
state: "clouds.services.monitoring.state", state: "clouds.services.monitoring.state",
actions: "clouds.services.monitoring.actions", actions: "clouds.services.monitoring.actions",
}, },
certificates: {
pem: "clouds.services.certificates.pem",
export: "clouds.services.certificates.export",
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",
@@ -926,6 +935,41 @@ export const endpointRegistry: EndpointRegistry = new Map([
[apiEndpointKeys.clouds.monitoring.history, {path: "/clouds/:cloud/services/monitoring/resources/:id/history", method: "GET"}], [apiEndpointKeys.clouds.monitoring.history, {path: "/clouds/:cloud/services/monitoring/resources/:id/history", method: "GET"}],
[apiEndpointKeys.clouds.monitoring.state, {path: "/clouds/:cloud/services/monitoring/resources/:id/state", method: "PUT"}], [apiEndpointKeys.clouds.monitoring.state, {path: "/clouds/:cloud/services/monitoring/resources/:id/state", method: "PUT"}],
[apiEndpointKeys.clouds.monitoring.actions, {path: "/clouds/:cloud/services/monitoring/resources/:id/actions", method: "PUT"}], [apiEndpointKeys.clouds.monitoring.actions, {path: "/clouds/:cloud/services/monitoring/resources/:id/actions", method: "PUT"}],
[
apiEndpointKeys.clouds.certificates.pem,
{
path: "/clouds/:cloud/services/certificates/resources/:id/pem",
method: "GET",
},
],
[
apiEndpointKeys.clouds.certificates.export,
{
path: "/clouds/:cloud/services/certificates/resources/:id/export",
method: "POST",
},
],
[
apiEndpointKeys.clouds.certificates.revoke,
{
path: "/clouds/:cloud/services/certificates/resources/:id/revoke",
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,10 @@ import type {
UserPoolSignInTestResult, UserPoolSignInTestResult,
UserPoolClientSettings, UserPoolClientSettings,
DistributionInvalidation, DistributionInvalidation,
AutoScalingCapacityInput,
CertificatePem,
CertificateRevocationReason,
ExportedCertificate,
AlarmHistoryItem, AlarmHistoryItem,
AlarmStateValue, AlarmStateValue,
MetricSeries, MetricSeries,
@@ -1679,6 +1683,70 @@ export async function testWebSocketApi(cloud: CloudProvider, apiId: string, inpu
return res.data; return res.data;
} }
export async function getCertificatePem(
cloud: CloudProvider,
certificateId: string,
): Promise<CertificatePem> {
const res = await apiClient.call<CertificatePem>(
apiEndpointKeys.clouds.certificates.pem,
requestOptions(cloud, "certificates"),
{ cloud, id: certificateId },
);
return res.data;
}
export async function exportCertificate(
cloud: CloudProvider,
certificateId: string,
passphrase: string,
): Promise<ExportedCertificate> {
const res = await apiClient.call<ExportedCertificate, { passphrase: string }>(
apiEndpointKeys.clouds.certificates.export,
requestOptions(cloud, "certificates", { body: { passphrase } }),
{ cloud, id: certificateId },
);
return res.data;
}
export async function revokeCertificate(
cloud: CloudProvider,
certificateId: string,
reason: CertificateRevocationReason,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource, { reason: CertificateRevocationReason }>(
apiEndpointKeys.clouds.certificates.revoke,
requestOptions(cloud, "certificates", { body: { reason } }),
{ cloud, id: certificateId },
);
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')
}
@@ -0,0 +1,88 @@
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 {CertificatePanel} from './CertificatePanel'
import type {CloudResource} from '@/types/resource'
const api = vi.hoisted(() => ({
getCloudResource: vi.fn(),
getCertificatePem: vi.fn(),
exportCertificate: vi.fn(),
revokeCertificate: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
const ID = '5ed9dc37-d9fb-456e-911e-e87f091480ba'
function certificate(metadata: Record<string, unknown> = {}, status = 'ISSUED'): CloudResource {
return {
id: ID, name: 'notes.thecloud.localhost', cloud: 'aws', service: 'certificates', type: 'certificate',
region: 'us-east-1', createdAt: null, status,
metadata: {
certificateType: 'Amazon issued', exportable: false, inUseBy: [],
domainValidations: [{domainName: 'notes.thecloud.localhost', status: 'SUCCESS', method: 'DNS', record: {name: '_a1.notes.thecloud.localhost.', type: 'CNAME', value: '_e8.acm-validations.aws.'}}],
...metadata,
},
}
}
function renderPanel(resource: CloudResource) {
api.getCloudResource.mockResolvedValue(resource)
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={client}><CertificatePanel resource={resource} runtimeReachable/></QueryClientProvider>)
}
beforeEach(() => {
vi.clearAllMocks()
api.getCertificatePem.mockResolvedValue({certificate: '-----BEGIN CERTIFICATE-----\nLEAF', certificateChain: '-----BEGIN CERTIFICATE-----\nCA'})
api.exportCertificate.mockResolvedValue({certificate: 'LEAF', certificateChain: null, privateKey: '-----BEGIN ENCRYPTED PRIVATE KEY-----'})
api.revokeCertificate.mockResolvedValue(certificate({exportable: true}, 'REVOKED'))
})
test('shows the validation records and that nothing uses the certificate', async () => {
renderPanel(certificate())
expect(await screen.findByText('_a1.notes.thecloud.localhost.')).toBeInTheDocument()
expect(screen.getByText('_e8.acm-validations.aws.')).toBeInTheDocument()
expect(screen.getByText(/Nothing uses this certificate/)).toBeInTheDocument()
// Export and revoke exist only for certificates requested as exportable, as on AWS.
expect(screen.queryByRole('form', {name: 'Export certificate'})).not.toBeInTheDocument()
expect(screen.queryByRole('form', {name: 'Revoke certificate'})).not.toBeInTheDocument()
})
test('lists what uses the certificate, since ACM will not delete it then', async () => {
renderPanel(certificate({inUseBy: ['arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1']}))
expect(await screen.findByText('arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1')).toBeInTheDocument()
expect(screen.getByText(/will not delete the certificate/)).toBeInTheDocument()
})
test('gets the certificate and chain as downloadable PEM files', async () => {
const user = userEvent.setup()
renderPanel(certificate())
await user.click(await screen.findByRole('button', {name: 'Get certificate'}))
expect(await screen.findByRole('textbox', {name: 'Certificate'})).toHaveValue('-----BEGIN CERTIFICATE-----\nLEAF')
expect(screen.getByRole('textbox', {name: 'Certificate chain'})).toHaveValue('-----BEGIN CERTIFICATE-----\nCA')
expect(screen.getByRole('link', {name: /notes.thecloud.localhost.pem/})).toHaveAttribute('download', 'notes.thecloud.localhost.pem')
})
test('exports an exportable certificate with the passphrase, then revokes it', async () => {
const user = userEvent.setup()
vi.spyOn(window, 'confirm').mockReturnValue(true)
renderPanel(certificate({exportable: true}))
const exportForm = await screen.findByRole('form', {name: 'Export certificate'})
await user.type(within(exportForm).getByLabelText(/Passphrase/), 'open-sesame')
await user.click(within(exportForm).getByRole('button', {name: 'Export'}))
expect(api.exportCertificate).toHaveBeenCalledWith('aws', ID, 'open-sesame')
expect(await screen.findByRole('textbox', {name: 'Private key (encrypted)'})).toHaveValue('-----BEGIN ENCRYPTED PRIVATE KEY-----')
const revokeForm = screen.getByRole('form', {name: 'Revoke certificate'})
await user.selectOptions(within(revokeForm).getByLabelText('Reason'), 'SUPERSEDED')
await user.click(within(revokeForm).getByRole('button', {name: 'Revoke'}))
expect(api.revokeCertificate).toHaveBeenCalledWith('aws', ID, 'SUPERSEDED')
expect(await screen.findByText(/Certificate revoked/)).toBeInTheDocument()
})
@@ -0,0 +1,166 @@
import {Download, RefreshCw} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {exportCertificate, getCertificatePem, getCloudResource, revokeCertificate} from '@/api/cloudProxyClient'
import {CopyValueButton} from '@/components/CopyValueButton'
import {useAccountId} from '@/lib/accountStore'
import {formatDateTime} from '@/lib/format'
import type {CertificatePem, CertificateRevocationReason, CloudResource, ExportedCertificate} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
type DomainValidation = {domainName: string; status: string | null; method: string | null; record: {name: string; type: string; value: string} | null}
const REVOCATION_REASONS: Array<{value: CertificateRevocationReason; label: string}> = [
{value: 'UNSPECIFIED', label: 'Unspecified'},
{value: 'KEY_COMPROMISE', label: 'Key compromise'},
{value: 'SUPERSEDED', label: 'Superseded'},
{value: 'AFFILIATION_CHANGED', label: 'Affiliation changed'},
{value: 'CESSATION_OF_OPERATION', label: 'Cessation of operation'},
{value: 'PRIVILEGE_WITHDRAWN', label: 'Privilege withdrawn'},
{value: 'CA_COMPROMISE', label: 'CA compromise'},
]
export function CertificatePanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [passphrase, setPassphrase] = useState('')
const [reason, setReason] = useState<CertificateRevocationReason>('UNSPECIFIED')
const [pem, setPem] = useState<CertificatePem | ExportedCertificate | null>(null)
const [notice, setNotice] = useState('')
const certificateKey = ['acm-certificate', accountId, resource?.id]
// The table row is a ListCertificates summary; validation records and users come from DescribeCertificate.
const certificateQuery = useQuery({
queryKey: certificateKey,
queryFn: ({signal}) => getCloudResource('aws', 'certificates', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
})
useEffect(() => {
setPassphrase('')
setReason('UNSPECIFIED')
setPem(null)
setNotice('')
}, [resource?.id])
const refresh = () => Promise.all([
queryClient.invalidateQueries({queryKey: certificateKey}),
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'certificates']}),
])
const pemMutation = useMutation({
mutationFn: () => getCertificatePem('aws', resource?.id ?? ''),
onSuccess: setPem,
})
const exportMutation = useMutation({
mutationFn: () => exportCertificate('aws', resource?.id ?? '', passphrase),
onSuccess: (exported) => {
setPem(exported)
setPassphrase('')
setNotice('Exported. The private key is encrypted with your passphrase.')
},
})
const revokeMutation = useMutation({
mutationFn: () => revokeCertificate('aws', resource?.id ?? '', reason),
onSuccess: () => {
setNotice('Certificate revoked. It can no longer be renewed or exported.')
return refresh()
},
})
if (!resource) {
return <section className="table-panel"><div className="empty compact"><h3>Select a certificate</h3><p>Choose a certificate to see its validation records, where it is used, and to download or export it.</p></div></section>
}
const certificate = certificateQuery.data ?? resource
const metadata = certificate.metadata
const validations = list<DomainValidation>(metadata.domainValidations)
const inUseBy = list<string>(metadata.inUseBy)
const exportable = metadata.exportable === true
const issued = certificate.status === 'ISSUED'
const pending = pemMutation.isPending || exportMutation.isPending || revokeMutation.isPending
const disabled = !runtimeReachable || pending
const error = certificateQuery.error ?? pemMutation.error ?? exportMutation.error ?? revokeMutation.error
const fileStem = certificate.name.replace(/^\*\./, 'wildcard.').replace(/[^a-z0-9.-]/gi, '_')
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Certificate ${certificate.name}`}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Certificate</p><span className="muted">{certificate.status ?? 'Unknown'} · {String(metadata.certificateType ?? '')}{exportable ? ' · exportable' : ''}{metadata.notAfter ? ` · expires ${formatDateTime(String(metadata.notAfter))}` : ''}</span></div>
<div className="cdn-actions">
<button className="button" type="button" disabled={disabled} onClick={() => { setNotice(''); pemMutation.mutate() }}><Download size={14}/>{pemMutation.isPending ? 'Loading' : 'Get certificate'}</button>
<button className="button" type="button" disabled={!runtimeReachable || certificateQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{certificateQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
</div>
{validations.length > 0 && <>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Domains</p><span className="muted">On AWS, each name is validated by the CNAME record below before the certificate is issued. TheCloud issues it straight away.</span></div></div>
<div className="cognito-table"><table className="table"><thead><tr><th>Domain</th><th>Validation</th><th>CNAME name</th><th>CNAME value</th></tr></thead><tbody>{validations.map((validation) => (
<tr key={validation.domainName}>
<td className="mono">{validation.domainName}</td>
<td><code>{validation.status ?? '—'}</code>{validation.method ? ` ${validation.method}` : ''}</td>
<td className="mono dns-values">{validation.record ? <span className="copy-value">{validation.record.name}<CopyValueButton value={validation.record.name} label="CNAME name"/></span> : '—'}</td>
<td className="mono dns-values">{validation.record ? <span className="copy-value">{validation.record.value}<CopyValueButton value={validation.record.value} label="CNAME value"/></span> : '—'}</td>
</tr>
))}</tbody></table></div>
</>}
<div className="input-row resource-table-bar"><div><p className="eyebrow">In use by</p><span className="muted">{inUseBy.length > 0 ? 'ACM will not delete the certificate until these stop using it.' : 'Nothing uses this certificate, so it can be deleted.'}</span></div></div>
{inUseBy.length > 0 && <ul className="certificate-users">{inUseBy.map((arn) => <li key={arn} className="mono">{arn}</li>)}</ul>}
{exportable && issued && <>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Export</p><span className="muted">Download the certificate with its private key, encrypted with a passphrase, to use on a server outside AWS.</span></div></div>
<form className="dynamic-form" aria-label="Export certificate" onSubmit={(event) => { event.preventDefault(); setNotice(''); exportMutation.mutate() }}>
<label className="dynamic-field"><span>Passphrase</span>
<input className="input" type="password" autoComplete="new-password" value={passphrase} onChange={(event) => setPassphrase(event.target.value)} disabled={disabled} minLength={4} maxLength={128} required/>
<small className="muted">4 to 128 characters, without #, $ or %. You need it to decrypt the key.</small>
</label>
<div><button className="button primary" type="submit" disabled={disabled || passphrase.length < 4}>{exportMutation.isPending ? 'Exporting' : 'Export'}</button></div>
</form>
</>}
{exportable && certificate.status !== 'REVOKED' && <>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Revoke</p><span className="muted">Revoking cannot be undone. Use it if the private key leaked or the certificate is no longer needed.</span></div></div>
<form className="dynamic-form" aria-label="Revoke certificate" onSubmit={(event) => {
event.preventDefault()
setNotice('')
if (window.confirm(`Revoke the certificate for ${certificate.name}? This cannot be undone.`)) revokeMutation.mutate()
}}>
<label className="dynamic-field"><span>Reason</span>
<select className="input" value={reason} onChange={(event) => setReason(event.target.value as CertificateRevocationReason)} disabled={disabled}>
{REVOCATION_REASONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<div><button className="button danger" type="submit" disabled={disabled}>{revokeMutation.isPending ? 'Revoking' : 'Revoke'}</button></div>
</form>
</>}
{pem && <div className="certificate-pem">
<PemBlock label="Certificate" value={pem.certificate} file={`${fileStem}.pem`}/>
{pem.certificateChain && <PemBlock label="Certificate chain" value={pem.certificateChain} file={`${fileStem}-chain.pem`}/>}
{'privateKey' in pem && <PemBlock label="Private key (encrypted)" value={pem.privateKey} file={`${fileStem}-key.pem`}/>}
</div>}
{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>
}
function PemBlock({label, value, file}: {label: string; value: string; file: string}) {
const href = `data:application/x-pem-file;charset=utf-8,${encodeURIComponent(value)}`
return <div className="certificate-pem-block">
<div className="certificate-pem-head">
<strong>{label}</strong>
<CopyValueButton value={value} label={label}/>
<a className="button" href={href} download={file}><Download size={13}/> {file}</a>
</div>
<textarea className="textarea code-textarea small" rows={6} value={value} readOnly aria-label={label}/>
</div>
}
function list<T>(value: unknown): T[] {
return Array.isArray(value) ? value as T[] : []
}
@@ -68,6 +68,9 @@ 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 { PipePanel } from "@/components/PipePanel";
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel"; import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
import { CloudWatchMetricsExplorer } from "@/components/CloudWatchMetricsExplorer"; import { CloudWatchMetricsExplorer } from "@/components/CloudWatchMetricsExplorer";
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger"; import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
@@ -987,6 +990,15 @@ export function DynamicResourceView({
{service === "dns" && cloud === "aws" && ( {service === "dns" && cloud === "aws" && (
<Route53RecordsPanel resource={activeSelected} runtimeReachable={canUseRuntime} /> <Route53RecordsPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)} )}
{service === "certificates" && cloud === "aws" && (
<CertificatePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "pipes" && cloud === "aws" && (
<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} />
)} )}
@@ -0,0 +1,81 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {MemoryRouter} from 'react-router-dom'
import {beforeEach, expect, test, vi} from 'vitest'
import {PipePanel} from './PipePanel'
import type {CloudResource} from '@/types/resource'
const api = vi.hoisted(() => ({
getCloudResource: vi.fn(),
startCloudResource: vi.fn(),
stopCloudResource: vi.fn(),
sendSqsMessage: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
function pipe(status = 'RUNNING', metadata: Record<string, unknown> = {}): CloudResource {
return {
id: 'orders-pipe', name: 'orders-pipe', cloud: 'aws', service: 'pipes', type: 'pipe', region: 'us-east-1', createdAt: null, status,
metadata: {
desiredState: status, source: 'arn:aws:sqs:us-east-1:000000000000:pipe-src', sourceName: 'pipe-src (SQS queue)',
target: 'arn:aws:lambda:us-east-1:000000000000:function:worker', targetName: 'worker (Lambda function)',
filterPatterns: ['{"body":{"kind":["order"]}}'], batchSize: 5, inputTemplate: '{"id": <$.messageId>}',
roleArn: 'arn:aws:iam::000000000000:role/pipe-role',
...metadata,
},
}
}
function renderPanel(resource: CloudResource) {
api.getCloudResource.mockResolvedValue(resource)
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<MemoryRouter><QueryClientProvider client={client}><PipePanel resource={resource} runtimeReachable/></QueryClientProvider></MemoryRouter>)
}
beforeEach(() => {
vi.clearAllMocks()
api.startCloudResource.mockResolvedValue(undefined)
api.stopCloudResource.mockResolvedValue(undefined)
api.sendSqsMessage.mockResolvedValue({messageId: 'm-1'})
})
test('shows the flow from source through the filter to the target, linked to each service', async () => {
renderPanel(pipe())
const flow = await screen.findByRole('list', {name: 'Event flow'})
expect(within(flow).getByRole('link', {name: 'pipe-src (SQS queue)'})).toHaveAttribute('href', '/cloud-explorer/aws/messaging')
expect(within(flow).getByRole('link', {name: 'worker (Lambda function)'})).toHaveAttribute('href', '/cloud-explorer/aws/serverless')
expect(within(flow).getByText('{"body":{"kind":["order"]}}')).toBeInTheDocument()
expect(within(flow).getByText('batches of 5')).toBeInTheDocument()
expect(within(flow).getByText('{"id": <$.messageId>}')).toBeInTheDocument()
})
test('stops a running pipe', async () => {
const user = userEvent.setup()
renderPanel(pipe())
await user.click(await screen.findByRole('button', {name: 'Stop'}))
expect(api.stopCloudResource).toHaveBeenCalledWith('aws', 'pipes', 'orders-pipe')
expect(await screen.findByText(/Pipe stopped/)).toBeInTheDocument()
})
test('sends a test message to an SQS source queue', async () => {
const user = userEvent.setup()
renderPanel(pipe())
const form = await screen.findByRole('form', {name: 'Send test message'})
await user.click(within(form).getByRole('button', {name: 'Send to pipe-src'}))
expect(api.sendSqsMessage).toHaveBeenCalledWith('aws', 'pipe-src', {messageBody: '{"kind": "order", "id": 1}'})
expect(await screen.findByText(/Sent message m-1/)).toBeInTheDocument()
})
test('offers no test message for a stream source, and cannot toggle a pipe mid-change', async () => {
renderPanel(pipe('STARTING', {source: 'arn:aws:kinesis:us-east-1:000000000000:stream/clicks', sourceName: 'clicks (Kinesis stream)', desiredState: 'RUNNING'}))
expect(await screen.findByRole('link', {name: 'clicks (Kinesis stream)'})).toBeInTheDocument()
expect(screen.queryByRole('form', {name: 'Send test message'})).not.toBeInTheDocument()
expect(screen.getByRole('button', {name: 'Start'})).toBeDisabled()
})
@@ -0,0 +1,135 @@
import {ArrowRight, RefreshCw} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {type ReactNode, useEffect, useState} from 'react'
import {Link} from 'react-router-dom'
import {getCloudResource, sendSqsMessage, startCloudResource, stopCloudResource} from '@/api/cloudProxyClient'
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
import {useAccountId} from '@/lib/accountStore'
import type {CloudResource} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
/** Where each ARN's service lives in TheCloud, so a step links to the resource behind it. */
const CONSOLE_SERVICE: Record<string, string> = {
sqs: 'messaging',
lambda: 'serverless',
sns: 'notifications',
events: 'events',
states: 'workflows',
kinesis: 'kinesis',
dynamodb: 'nosql',
}
const SAMPLE_MESSAGE = '{"kind": "order", "id": 1}'
export function PipePanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [message, setMessage] = useState(SAMPLE_MESSAGE)
const [notice, setNotice] = useState('')
const pipeKey = ['pipe', accountId, resource?.id]
// The table row is a ListPipes entry; the filter, template and role come from DescribePipe.
const pipeQuery = useQuery({
queryKey: pipeKey,
queryFn: ({signal}) => getCloudResource('aws', 'pipes', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
})
useEffect(() => {
setMessage(SAMPLE_MESSAGE)
setNotice('')
}, [resource?.id])
const refresh = () => Promise.all([
queryClient.invalidateQueries({queryKey: pipeKey}),
queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources('aws', 'pipes')}),
])
const stateMutation = useMutation({
mutationFn: (running: boolean) => running ? startCloudResource('aws', 'pipes', resource?.id ?? '') : stopCloudResource('aws', 'pipes', resource?.id ?? ''),
onSuccess: (_result, running) => {
setNotice(running ? 'Pipe started. It reads the source again.' : 'Pipe stopped. Records wait in the source until it starts again.')
return refresh()
},
})
const sendMutation = useMutation({
mutationFn: (queue: string) => sendSqsMessage('aws', queue, {messageBody: message}),
onSuccess: (sent) => setNotice(`Sent message ${sent.messageId} to the source queue. The pipe picks it up within a few seconds.`),
})
if (!resource) {
return <section className="table-panel"><div className="empty compact"><h3>Select a pipe</h3><p>Choose a pipe to see where its events flow, start or stop it, or send it a test message.</p></div></section>
}
const pipe = pipeQuery.data ?? resource
const metadata = pipe.metadata
const state = pipe.status ?? 'UNKNOWN'
const running = state === 'RUNNING'
const filters = Array.isArray(metadata.filterPatterns) ? metadata.filterPatterns as string[] : []
const source = text(metadata.source)
const sourceQueue = source.split(':')[2] === 'sqs' ? source.split(':')[5] : ''
const pending = stateMutation.isPending || sendMutation.isPending
const disabled = !runtimeReachable || pending
const error = pipeQuery.error ?? stateMutation.error ?? sendMutation.error
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Pipe ${pipe.name}`}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Pipe</p><span className="muted">{state}{metadata.desiredState && metadata.desiredState !== state ? ` · going to ${String(metadata.desiredState)}` : ''}{metadata.description ? ` · ${String(metadata.description)}` : ''}</span></div>
<div className="cdn-actions">
<button className="button" type="button" disabled={disabled || !['RUNNING', 'STOPPED'].includes(state)} onClick={() => { setNotice(''); stateMutation.mutate(!running) }}>{running ? 'Stop' : 'Start'}</button>
<button className="button" type="button" disabled={!runtimeReachable || pipeQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{pipeQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
</div>
{typeof metadata.stateReason === 'string' && metadata.stateReason && <p className="error-text compact-text pipe-reason">{metadata.stateReason}</p>}
<ol className="pipe-flow" aria-label="Event flow">
<PipeStep label="Source" arn={source} name={metadata.sourceName} detail={sourceDetail(metadata)}/>
{filters.length > 0 && <li className="pipe-step"><span className="eyebrow">Filter</span>{filters.map((pattern) => <code key={pattern} className="pipe-code">{pattern}</code>)}<ArrowRight size={14} className="pipe-arrow" aria-hidden/></li>}
{text(metadata.enrichment) && <PipeStep label="Enrichment" arn={text(metadata.enrichment)} name={null}/>}
<PipeStep label="Target" arn={text(metadata.target)} name={metadata.targetName} detail={text(metadata.inputTemplate) ? <>input template <code className="pipe-code">{text(metadata.inputTemplate)}</code></> : undefined}/>
</ol>
{text(metadata.roleArn) && <p className="muted compact-text pipe-role">Runs as <code>{text(metadata.roleArn)}</code></p>}
{sourceQueue && <>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Try it</p><span className="muted">Send a message to the source queue{filters.length > 0 ? '. Only messages matching the filter reach the target' : ''}.</span></div></div>
<form className="dynamic-form" aria-label="Send test message" onSubmit={(event) => { event.preventDefault(); setNotice(''); sendMutation.mutate(sourceQueue) }}>
<label className="dynamic-field dynamic-field--span"><span>Message body</span>
<textarea className="textarea code-textarea small" rows={3} value={message} onChange={(event) => setMessage(event.target.value)} disabled={disabled} required/>
</label>
<div><button className="button primary" type="submit" disabled={disabled || !message.trim()}>{sendMutation.isPending ? 'Sending' : `Send to ${sourceQueue}`}</button></div>
</form>
</>}
{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>
}
function PipeStep({label, arn, name, detail}: {label: string; arn: string; name: unknown; detail?: ReactNode}) {
const service = CONSOLE_SERVICE[arn.split(':')[2] ?? '']
const title = typeof name === 'string' && name ? name : arn
return <li className="pipe-step">
<span className="eyebrow">{label}</span>
{service ? <Link to={`/cloud-explorer/aws/${service}`} className="mono">{title}</Link> : <span className="mono">{title}</span>}
<small className="muted mono pipe-arn">{arn}</small>
{detail && <small className="muted">{detail}</small>}
<ArrowRight size={14} className="pipe-arrow" aria-hidden/>
</li>
}
function sourceDetail(metadata: Record<string, unknown>): ReactNode {
const parts = [
metadata.batchSize ? `batches of ${String(metadata.batchSize)}` : '',
metadata.startingPosition ? `from ${String(metadata.startingPosition)}` : '',
metadata.topicName ? `topic ${String(metadata.topicName)}` : '',
].filter(Boolean)
return parts.length > 0 ? parts.join(' · ') : undefined
}
function text(value: unknown): string {
return typeof value === 'string' ? value : ''
}
@@ -6,6 +6,7 @@ import {
Container, Container,
Database, Database,
Earth, Earth,
FileBadge,
Globe, Globe,
HardDrive, HardDrive,
Hammer, Hammer,
@@ -15,6 +16,7 @@ import {
MessageSquare, MessageSquare,
Network, Network,
Radio, Radio,
Scaling,
ScrollText, ScrollText,
Scale, Scale,
Server, Server,
@@ -36,6 +38,8 @@ 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 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'
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg' import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
@@ -87,6 +91,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
iac: Layers, iac: Layers,
messaging: MessageSquare, messaging: MessageSquare,
events: Radio, events: Radio,
pipes: Workflow,
email: MessageSquare, email: MessageSquare,
queue: MessageSquare, queue: MessageSquare,
logs: ScrollText, logs: ScrollText,
@@ -95,6 +100,8 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
userpools: UsersRound, userpools: UsersRound,
dns: Globe, dns: Globe,
cdn: Earth, cdn: Earth,
autoscaling: Scaling,
certificates: FileBadge,
monitoring: Activity, monitoring: Activity,
loadbalancing: Scale, loadbalancing: Scale,
parameters: SlidersHorizontal, parameters: SlidersHorizontal,
@@ -121,6 +128,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
ecr: ecrIcon, ecr: ecrIcon,
email: sesIcon, email: sesIcon,
events: eventBridgeIcon, events: eventBridgeIcon,
pipes: eventBridgeIcon,
iac: cloudFormationIcon, iac: cloudFormationIcon,
iam: iamIcon, iam: iamIcon,
kms: kmsIcon, kms: kmsIcon,
@@ -142,6 +150,8 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
userpools: cognitoIcon, userpools: cognitoIcon,
dns: route53Icon, dns: route53Icon,
cdn: cloudFrontIcon, cdn: cloudFrontIcon,
autoscaling: autoScalingIcon,
certificates: certificateManagerIcon,
monitoring: cloudWatchIcon, monitoring: cloudWatchIcon,
workflows: stepFunctionsIcon, workflows: stepFunctionsIcon,
} }
+20
View File
@@ -6610,3 +6610,23 @@ tr[aria-current="true"] td { background: var(--hover-md); }
.metric-chart-table { margin-top: 8px; } .metric-chart-table { margin-top: 8px; }
.metric-chart-table summary { cursor: pointer; color: var(--text-3); font-size: 12px; } .metric-chart-table summary { cursor: pointer; color: var(--text-3); font-size: 12px; }
.metric-chart-table table { margin-top: 8px; } .metric-chart-table table { margin-top: 8px; }
/* ACM: the resources using a certificate, and its PEM blocks. */
.certificate-users { margin: 0; padding: 0 16px 12px 32px; display: grid; gap: 4px; overflow-wrap: anywhere; }
.certificate-pem { display: grid; gap: 12px; padding: 0 16px 16px; }
.certificate-pem-block { display: grid; gap: 6px; min-width: 0; }
.certificate-pem-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
/* EventBridge Pipes: source, filter, enrichment and target as one flow. */
.pipe-flow { display: flex; flex-wrap: wrap; gap: 8px 28px; margin: 0; padding: 4px 16px 12px; list-style: none; }
.pipe-step { position: relative; display: grid; gap: 4px; align-content: start; min-width: 0; max-width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 4px; background: var(--raised); }
.pipe-step .pipe-arrow { position: absolute; top: 50%; right: -22px; transform: translateY(-50%); color: var(--text-3); }
.pipe-step:last-child .pipe-arrow { display: none; }
.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; }
+25
View File
@@ -337,6 +337,31 @@ export interface AlarmHistoryItem {
summary: string summary: string
} }
export interface CertificatePem {
certificate: string
certificateChain: string | null
}
/** The private key is PEM, encrypted with the passphrase given to export. */
export interface ExportedCertificate extends CertificatePem {
privateKey: string
}
export type CertificateRevocationReason =
| 'UNSPECIFIED'
| 'KEY_COMPROMISE'
| 'CA_COMPROMISE'
| 'AFFILIATION_CHANGED'
| 'SUPERSEDED'
| 'CESSATION_OF_OPERATION'
| '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
+4
View File
@@ -28,6 +28,8 @@ 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 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'
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon' export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
@@ -91,6 +93,8 @@ 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>>
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>> monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
} }
filters: FieldSchema[] filters: FieldSchema[]
+54
View File
@@ -10,6 +10,9 @@ importers:
packages/api: packages/api:
dependencies: dependencies:
'@aws-sdk/client-acm':
specifier: ^3.1146.0
version: 3.1156.0
'@aws-sdk/client-api-gateway': '@aws-sdk/client-api-gateway':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1096.0 version: 3.1096.0
@@ -19,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
@@ -88,6 +94,9 @@ importers:
'@aws-sdk/client-lightsail': '@aws-sdk/client-lightsail':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1148.0 version: 3.1148.0
'@aws-sdk/client-pipes':
specifier: ^3.1146.0
version: 3.1156.0
'@aws-sdk/client-rds': '@aws-sdk/client-rds':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1076.0 version: 3.1076.0
@@ -298,6 +307,10 @@ packages:
resolution: {integrity: sha512-USI1N1d+NbCcfjeiYLKIl94PSYykl5IIjj4X3WqdL33Ln5qktA6sjXYct0o/gG2Cte7cuzbXKkDBkzUA4VPHrg==} resolution: {integrity: sha512-USI1N1d+NbCcfjeiYLKIl94PSYykl5IIjj4X3WqdL33Ln5qktA6sjXYct0o/gG2Cte7cuzbXKkDBkzUA4VPHrg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/client-acm@3.1156.0':
resolution: {integrity: sha512-vRy8mVgYOO1E/YXDlBuXCUKsmNKkVjKFIW2ZS30kWZydbS6CnqFxNkMajDzqJcm+PZ74rwgnhNHsJkc+UIEMzQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-api-gateway@3.1096.0': '@aws-sdk/client-api-gateway@3.1096.0':
resolution: {integrity: sha512-RPzDojG3/YTdOMWGQ9mhE51db0IxL8YSjN9fKe5UawwgGrLa915ik71SMYk4z7hywkANuPTqRtt/kNJ863/j0g==} resolution: {integrity: sha512-RPzDojG3/YTdOMWGQ9mhE51db0IxL8YSjN9fKe5UawwgGrLa915ik71SMYk4z7hywkANuPTqRtt/kNJ863/j0g==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -310,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'}
@@ -402,6 +419,10 @@ packages:
resolution: {integrity: sha512-sGXHmokMg79uwzFb9aAjC3gSE5LllEJii6aARHbnbDESFGIT73ZbUGBuNDWmF9N4DkkBAPlDeoATOHe4p7Qp5Q==} resolution: {integrity: sha512-sGXHmokMg79uwzFb9aAjC3gSE5LllEJii6aARHbnbDESFGIT73ZbUGBuNDWmF9N4DkkBAPlDeoATOHe4p7Qp5Q==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/client-pipes@3.1156.0':
resolution: {integrity: sha512-sHtPycrT80c+bEkQgFJIh+CzpuqjGhhdGtVEQfBU+APmHF01SZEJrbnY7MZEHEJZl0iVgLuasxtlYQO+rhAkoQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-rds-data@3.1144.0': '@aws-sdk/client-rds-data@3.1144.0':
resolution: {integrity: sha512-6WIjf0rUcT2G//bjsHET1BbQ4sBJ/oR5CEon7ehOI86tr1zQj8XggGqJBuRHvZ+jePabWkTmQcrkpra58Jn2gQ==} resolution: {integrity: sha512-6WIjf0rUcT2G//bjsHET1BbQ4sBJ/oR5CEon7ehOI86tr1zQj8XggGqJBuRHvZ+jePabWkTmQcrkpra58Jn2gQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -2896,6 +2917,17 @@ snapshots:
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/client-acm@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-api-gateway@3.1096.0': '@aws-sdk/client-api-gateway@3.1096.0':
dependencies: dependencies:
'@aws-sdk/core': 3.977.1 '@aws-sdk/core': 3.977.1
@@ -2930,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
@@ -3196,6 +3239,17 @@ snapshots:
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/client-pipes@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-rds-data@3.1144.0': '@aws-sdk/client-rds-data@3.1144.0':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.1