Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c0ef3091e | |||
| a2a9c19ff6 | |||
| 5eee0b7e9b | |||
| 69d80c60a3 | |||
| 26b635db0b | |||
| efaa2e6826 | |||
| 48114da772 | |||
| 70cfd4bc96 | |||
| 12b50f4868 |
@@ -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) |
|
||||||
@@ -69,6 +70,7 @@ cd packages/api && bun run scripts/service-matrix.ts
|
|||||||
| Developer Tools | CodeDeploy | Yes (list, create, delete, inspect) | No | No |
|
| Developer Tools | CodeDeploy | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Storage | Storage | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) |
|
| Storage | Storage | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) |
|
||||||
| Storage | ECR / Elastic Container Registry | Yes (list, create, delete, inspect) | No | No |
|
| Storage | ECR / Elastic Container Registry | Yes (list, create, delete, inspect) | No | No |
|
||||||
|
| Storage | EFS / File Systems | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Databases | Database | Yes (list, create, update, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
|
| Databases | Database | Yes (list, create, update, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
|
||||||
| Databases | DynamoDB / Cosmos DB NoSQL / NoSQL | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | No |
|
| Databases | DynamoDB / Cosmos DB NoSQL / NoSQL | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | No |
|
||||||
| Databases | ElastiCache | Yes (list, create, delete, inspect) | No | No |
|
| Databases | ElastiCache | Yes (list, create, delete, inspect) | No | No |
|
||||||
@@ -81,12 +83,15 @@ 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 |
|
||||||
| Analytics | MSK / Managed Streaming for Kafka | Yes (legacy page) | No | No |
|
| Analytics | MSK / Managed Streaming for Kafka | Yes (legacy page) | No | No |
|
||||||
| Analytics | MSK Connect / Managed Streaming for Kafka Connect | Yes (legacy page) | No | No |
|
| Analytics | MSK Connect / Managed Streaming for Kafka Connect | Yes (legacy page) | No | No |
|
||||||
| Analytics | Kinesis / Kinesis Data Streams | Yes (list, create, inspect, delete) | No | No |
|
| Analytics | Kinesis / Kinesis Data Streams | Yes (list, create, inspect, delete) | No | No |
|
||||||
|
| Analytics | Glue Data Catalog / Data Catalog | Yes (list, create, delete, inspect) | No | No |
|
||||||
|
| Analytics | Athena / Query | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Provisioning | CloudFormation / Infrastructure as Code | Yes (list, create, update, delete, inspect) | No | No |
|
| Provisioning | CloudFormation / Infrastructure as Code | Yes (list, create, update, delete, inspect) | No | No |
|
||||||
| Provisioning | AppConfig / Configuration | Yes (list, create, delete, inspect) | No | No |
|
| Provisioning | AppConfig / Configuration | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Security | Identity | Yes (list, create, delete, inspect) | No | No |
|
| Security | Identity | Yes (list, create, delete, inspect) | No | No |
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"@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-athena": "^3.1146.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",
|
||||||
@@ -26,10 +28,12 @@
|
|||||||
"@aws-sdk/client-ec2": "^3.1076.0",
|
"@aws-sdk/client-ec2": "^3.1076.0",
|
||||||
"@aws-sdk/client-ecr": "^3.1076.0",
|
"@aws-sdk/client-ecr": "^3.1076.0",
|
||||||
"@aws-sdk/client-ecs": "^3.1076.0",
|
"@aws-sdk/client-ecs": "^3.1076.0",
|
||||||
|
"@aws-sdk/client-efs": "^3.1146.0",
|
||||||
"@aws-sdk/client-eks": "^3.1076.0",
|
"@aws-sdk/client-eks": "^3.1076.0",
|
||||||
"@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0",
|
"@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0",
|
||||||
"@aws-sdk/client-elasticache": "^3.1146.0",
|
"@aws-sdk/client-elasticache": "^3.1146.0",
|
||||||
"@aws-sdk/client-eventbridge": "^3.1076.0",
|
"@aws-sdk/client-eventbridge": "^3.1076.0",
|
||||||
|
"@aws-sdk/client-glue": "^3.1146.0",
|
||||||
"@aws-sdk/client-iam": "^3.1076.0",
|
"@aws-sdk/client-iam": "^3.1076.0",
|
||||||
"@aws-sdk/client-kafka": "^3.1146.0",
|
"@aws-sdk/client-kafka": "^3.1146.0",
|
||||||
"@aws-sdk/client-kafkaconnect": "^3.1146.0",
|
"@aws-sdk/client-kafkaconnect": "^3.1146.0",
|
||||||
@@ -37,6 +41,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,155 @@
|
|||||||
|
import {describe, expect, test} from 'bun:test'
|
||||||
|
import {
|
||||||
|
CreateDatabaseCommand,
|
||||||
|
CreateTableCommand,
|
||||||
|
DeleteDatabaseCommand,
|
||||||
|
DeleteTableCommand,
|
||||||
|
GetDatabaseCommand,
|
||||||
|
GetDatabasesCommand,
|
||||||
|
GetTablesCommand,
|
||||||
|
type GlueClient,
|
||||||
|
} from '@aws-sdk/client-glue'
|
||||||
|
import {AwsDataCatalogAdapter, describeTable} from './AwsDataCatalogAdapter'
|
||||||
|
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
|
||||||
|
type SendResult = Record<string, unknown>
|
||||||
|
|
||||||
|
const database = {Name: 'shop', Description: 'Orders and customers', LocationUri: 's3://data/shop/', CatalogId: '000000000000', CreateTime: new Date('2026-10-25T18:00:00Z')}
|
||||||
|
const ordersTable = {
|
||||||
|
Name: 'orders',
|
||||||
|
TableType: 'EXTERNAL_TABLE',
|
||||||
|
Parameters: {classification: 'csv', 'skip.header.line.count': '1'},
|
||||||
|
StorageDescriptor: {
|
||||||
|
Columns: [{Name: 'id', Type: 'int'}, {Name: 'amount', Type: 'double'}],
|
||||||
|
Location: 's3://data/orders/',
|
||||||
|
InputFormat: 'org.apache.hadoop.mapred.TextInputFormat',
|
||||||
|
SerdeInfo: {SerializationLibrary: 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe', Parameters: {'field.delim': ';'}},
|
||||||
|
},
|
||||||
|
PartitionKeys: [{Name: 'day', Type: 'string'}],
|
||||||
|
}
|
||||||
|
const eventsTable = {
|
||||||
|
Name: 'events',
|
||||||
|
StorageDescriptor: {Columns: [{Name: 'kind', Type: 'string'}], Location: 's3://data/events/', SerdeInfo: {SerializationLibrary: 'org.openx.data.jsonserde.JsonSerDe'}},
|
||||||
|
}
|
||||||
|
|
||||||
|
function adapterWith(handler: (command: object) => SendResult | undefined = () => undefined) {
|
||||||
|
const sent: object[] = []
|
||||||
|
const client = {
|
||||||
|
async send(command: object) {
|
||||||
|
sent.push(command)
|
||||||
|
const result = handler(command)
|
||||||
|
if (result) return result
|
||||||
|
if (command instanceof GetDatabasesCommand) return {DatabaseList: [database]}
|
||||||
|
if (command instanceof GetDatabaseCommand) return {Database: database}
|
||||||
|
if (command instanceof GetTablesCommand) return {TableList: [ordersTable, eventsTable]}
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {sent, adapter: new AwsDataCatalogAdapter(client as unknown as GlueClient)}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AwsDataCatalogAdapter', () => {
|
||||||
|
test('lists databases across pages with their table counts', async () => {
|
||||||
|
const {adapter} = adapterWith((command) => {
|
||||||
|
if (command instanceof GetDatabasesCommand) {
|
||||||
|
return command.input.NextToken ? {DatabaseList: [{Name: 'logs'}]} : {DatabaseList: [database], NextToken: 'n'}
|
||||||
|
}
|
||||||
|
if (command instanceof GetTablesCommand && command.input.DatabaseName === 'logs') return {TableList: []}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const listed = await adapter.list()
|
||||||
|
expect(listed.map((db) => [db.name, db.metadata.tableCount, db.metadata.description])).toEqual([['shop', 2, 'Orders and customers'], ['logs', 0, null]])
|
||||||
|
expect((await adapter.list({search: 'customers'})).map((db) => db.name)).toEqual(['shop'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describes each table with its format, location and columns, sorted by name', async () => {
|
||||||
|
const {adapter} = adapterWith()
|
||||||
|
const detail = await adapter.get('shop')
|
||||||
|
const tables = detail?.metadata.tables as Array<Record<string, unknown>>
|
||||||
|
|
||||||
|
expect(tables.map((table) => table.name)).toEqual(['events', 'orders'])
|
||||||
|
expect(tables[1]).toMatchObject({format: 'csv', delimiter: ';', headerRow: true, location: 's3://data/orders/', columns: [{name: 'id', type: 'int'}, {name: 'amount', type: 'double'}], partitionKeys: [{name: 'day', type: 'string'}]})
|
||||||
|
expect(tables[0]).toMatchObject({format: 'json', delimiter: null, headerRow: false})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reads the format the way the query engine does', () => {
|
||||||
|
expect(describeTable({Name: 'p', StorageDescriptor: {InputFormat: 'org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat'}}).format).toBe('parquet')
|
||||||
|
expect(describeTable({Name: 'i', Parameters: {table_type: 'ICEBERG'}}).format).toBe('iceberg')
|
||||||
|
expect(describeTable({Name: 'c'}).format).toBe('csv')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null for a database that does not exist', async () => {
|
||||||
|
const missing = Object.assign(new Error('missing'), {name: 'EntityNotFoundException'})
|
||||||
|
const {adapter} = adapterWith((command) => { if (command instanceof GetDatabaseCommand) throw missing; return undefined })
|
||||||
|
expect(await adapter.get('nope')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a database with an optional description and location', async () => {
|
||||||
|
const {adapter, sent} = adapterWith()
|
||||||
|
await adapter.create({values: {name: 'shop', description: 'Orders', locationUri: 's3://data/shop/'}})
|
||||||
|
expect((sent.find((command) => command instanceof CreateDatabaseCommand) as CreateDatabaseCommand).input).toEqual({DatabaseInput: {Name: 'shop', Description: 'Orders', LocationUri: 's3://data/shop/'}})
|
||||||
|
|
||||||
|
await expect(adapter.create({values: {name: 'Shop'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.create({values: {name: 'shop', locationUri: 'https://example.com'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a CSV table Athena can read, with its delimiter and header row', async () => {
|
||||||
|
const {adapter, sent} = adapterWith()
|
||||||
|
await adapter.createCatalogTable('shop', {name: 'orders', location: 's3://data/orders', format: 'csv', delimiter: ';', headerRow: true, columns: [{name: 'id', type: 'int'}, {name: 'amount', type: 'DOUBLE'}]})
|
||||||
|
|
||||||
|
const create = sent.find((command) => command instanceof CreateTableCommand) as CreateTableCommand
|
||||||
|
expect(create.input).toEqual({
|
||||||
|
DatabaseName: 'shop',
|
||||||
|
TableInput: {
|
||||||
|
Name: 'orders',
|
||||||
|
TableType: 'EXTERNAL_TABLE',
|
||||||
|
Parameters: {classification: 'csv', EXTERNAL: 'TRUE', 'skip.header.line.count': '1'},
|
||||||
|
StorageDescriptor: {
|
||||||
|
Columns: [{Name: 'id', Type: 'int'}, {Name: 'amount', Type: 'double'}],
|
||||||
|
Location: 's3://data/orders/',
|
||||||
|
InputFormat: 'org.apache.hadoop.mapred.TextInputFormat',
|
||||||
|
OutputFormat: 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat',
|
||||||
|
SerdeInfo: {SerializationLibrary: 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe', Parameters: {'field.delim': ';'}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates JSON and Parquet tables with their own SerDe', async () => {
|
||||||
|
const {adapter, sent} = adapterWith()
|
||||||
|
await adapter.createCatalogTable('shop', {name: 'events', location: 's3://data/events/', format: 'json', columns: [{name: 'kind', type: 'string'}]})
|
||||||
|
await adapter.createCatalogTable('shop', {name: 'facts', location: 's3://data/facts/', format: 'parquet', columns: [{name: 'total', type: 'decimal(12,2)'}]})
|
||||||
|
|
||||||
|
const [json, parquet] = sent.filter((command) => command instanceof CreateTableCommand) as CreateTableCommand[]
|
||||||
|
expect(json.input.TableInput?.StorageDescriptor?.SerdeInfo).toEqual({SerializationLibrary: 'org.openx.data.jsonserde.JsonSerDe'})
|
||||||
|
expect(parquet.input.TableInput?.StorageDescriptor?.InputFormat).toBe('org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat')
|
||||||
|
expect(parquet.input.TableInput?.StorageDescriptor?.Columns).toEqual([{Name: 'total', Type: 'decimal(12,2)'}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses tables Athena could not query', async () => {
|
||||||
|
const {adapter} = adapterWith()
|
||||||
|
const table = {name: 'orders', location: 's3://data/orders/', format: 'csv' as const, columns: [{name: 'id', type: 'int'}]}
|
||||||
|
|
||||||
|
await expect(adapter.createCatalogTable('shop', {...table, columns: []})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.createCatalogTable('shop', {...table, columns: [{name: 'id', type: 'int'}, {name: 'id', type: 'string'}]})).rejects.toThrow(/listed twice/)
|
||||||
|
await expect(adapter.createCatalogTable('shop', {...table, columns: [{name: 'id', type: 'uuid'}]})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.createCatalogTable('shop', {...table, location: '/tmp/orders'})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.createCatalogTable('shop', {...table, delimiter: '||'})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.createCatalogTable('shop', {...table, format: 'avro' as never})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creating a table in a missing database is a not-found', async () => {
|
||||||
|
const missing = Object.assign(new Error('missing'), {name: 'EntityNotFoundException'})
|
||||||
|
const {adapter} = adapterWith((command) => { if (command instanceof GetDatabaseCommand) throw missing; return undefined })
|
||||||
|
await expect(adapter.createCatalogTable('nope', {name: 'orders', location: 's3://data/orders/', format: 'json', columns: [{name: 'id', type: 'int'}]})).rejects.toBeInstanceOf(NotFoundError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deletes tables and databases', async () => {
|
||||||
|
const {adapter, sent} = adapterWith()
|
||||||
|
await adapter.deleteCatalogTable('shop', 'orders')
|
||||||
|
await adapter.delete('shop')
|
||||||
|
expect((sent.find((command) => command instanceof DeleteTableCommand) as DeleteTableCommand).input).toEqual({DatabaseName: 'shop', Name: 'orders'})
|
||||||
|
expect((sent.find((command) => command instanceof DeleteDatabaseCommand) as DeleteDatabaseCommand).input).toEqual({Name: 'shop'})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import {
|
||||||
|
CreateDatabaseCommand,
|
||||||
|
CreateTableCommand,
|
||||||
|
type Database,
|
||||||
|
DeleteDatabaseCommand,
|
||||||
|
DeleteTableCommand,
|
||||||
|
GetDatabaseCommand,
|
||||||
|
GetDatabasesCommand,
|
||||||
|
GetTablesCommand,
|
||||||
|
type GlueClient,
|
||||||
|
type StorageDescriptor,
|
||||||
|
type Table,
|
||||||
|
} from '@aws-sdk/client-glue'
|
||||||
|
import {NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
import {awsDataCatalogSchema, CATALOG_COLUMN_TYPES, CATALOG_NAME_PATTERN, CATALOG_TABLE_FORMATS} from '../cloud-spi/dataCatalogSchema'
|
||||||
|
import type {CatalogTableInput, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||||
|
|
||||||
|
const S3_URI = /^s3:\/\/[a-z0-9.-]{3,63}(\/.*)?$/
|
||||||
|
const DECIMAL_TYPE = /^decimal\(\d{1,2},\d{1,2}\)$/
|
||||||
|
|
||||||
|
/** The Hive storage settings Athena expects for each format, as the Glue console writes them. */
|
||||||
|
const STORAGE_FOR_FORMAT = {
|
||||||
|
csv: {
|
||||||
|
InputFormat: 'org.apache.hadoop.mapred.TextInputFormat',
|
||||||
|
OutputFormat: 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat',
|
||||||
|
SerializationLibrary: 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe',
|
||||||
|
},
|
||||||
|
json: {
|
||||||
|
InputFormat: 'org.apache.hadoop.mapred.TextInputFormat',
|
||||||
|
OutputFormat: 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat',
|
||||||
|
SerializationLibrary: 'org.openx.data.jsonserde.JsonSerDe',
|
||||||
|
},
|
||||||
|
parquet: {
|
||||||
|
InputFormat: 'org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat',
|
||||||
|
OutputFormat: 'org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat',
|
||||||
|
SerializationLibrary: 'org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talks to the AWS Glue Data Catalog, verified against Floci core (community, `latest`).
|
||||||
|
*
|
||||||
|
* Notes from probing the runtime:
|
||||||
|
* - The catalog is what TheCloud's Athena reads: a table over CSV, JSON lines
|
||||||
|
* or Parquet files in S3 can be queried as soon as it exists.
|
||||||
|
* - Glue jobs and crawlers are stored but never run, so the console offers
|
||||||
|
* only the catalog: databases and the tables in them.
|
||||||
|
* - DeleteDatabase removes the database's tables too, as it does in AWS.
|
||||||
|
*/
|
||||||
|
export class AwsDataCatalogAdapter implements CloudServiceAdapter {
|
||||||
|
readonly cloud = 'aws' as const
|
||||||
|
readonly service = 'datacatalog' as const
|
||||||
|
|
||||||
|
constructor(private readonly glue: GlueClient) {}
|
||||||
|
|
||||||
|
schema(): ServiceSchema {
|
||||||
|
return awsDataCatalogSchema()
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||||
|
const found: Database[] = []
|
||||||
|
let next: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.glue.send(new GetDatabasesCommand({MaxResults: 100, ...(next ? {NextToken: next} : {})}))
|
||||||
|
found.push(...(res.DatabaseList ?? []))
|
||||||
|
next = res.NextToken
|
||||||
|
} while (next)
|
||||||
|
|
||||||
|
const search = query.search?.trim().toLowerCase()
|
||||||
|
const matching = found.filter((database) => !search
|
||||||
|
|| (database.Name ?? '').toLowerCase().includes(search)
|
||||||
|
|| (database.Description ?? '').toLowerCase().includes(search))
|
||||||
|
return Promise.all(matching.map(async (database) => toResource(database, {tableCount: (await this.tables(database.Name ?? '')).length})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<CloudResource | null> {
|
||||||
|
const database = await this.database(id)
|
||||||
|
if (!database) return null
|
||||||
|
const tables = await this.tables(id)
|
||||||
|
return toResource(database, {tableCount: tables.length, tables})
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||||
|
const values = input.values
|
||||||
|
const name = catalogName(values.name, 'name')
|
||||||
|
const description = text(values.description)
|
||||||
|
const locationUri = text(values.locationUri)
|
||||||
|
if (locationUri && !S3_URI.test(locationUri)) throw new ValidationError('locationUri must be an S3 URI such as s3://my-bucket/warehouse/')
|
||||||
|
|
||||||
|
await this.glue.send(new CreateDatabaseCommand({
|
||||||
|
DatabaseInput: {
|
||||||
|
Name: name,
|
||||||
|
...(description ? {Description: description} : {}),
|
||||||
|
...(locationUri ? {LocationUri: locationUri} : {}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
const created = await this.get(name)
|
||||||
|
if (!created) throw new RuntimeError(`Glue did not return the new database ${name}`)
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.glue.send(new DeleteDatabaseCommand({Name: id}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCatalogTable(databaseName: string, input: CatalogTableInput): Promise<CloudResource> {
|
||||||
|
const name = catalogName(input.name, 'name')
|
||||||
|
const location = text(input.location)
|
||||||
|
if (!S3_URI.test(location)) throw new ValidationError('location must be an S3 URI such as s3://my-bucket/orders/')
|
||||||
|
const format = text(input.format) as CatalogTableInput['format']
|
||||||
|
if (!(CATALOG_TABLE_FORMATS as readonly string[]).includes(format)) throw new ValidationError(`format must be one of ${CATALOG_TABLE_FORMATS.join(', ')}`)
|
||||||
|
const columns = tableColumns(input.columns)
|
||||||
|
const delimiter = typeof input.delimiter === 'string' && input.delimiter !== '' ? input.delimiter : ','
|
||||||
|
if (format === 'csv' && [...delimiter].length !== 1) throw new ValidationError('delimiter must be a single character')
|
||||||
|
const description = text(input.description)
|
||||||
|
if (!await this.database(databaseName)) throw new NotFoundError(`Database ${databaseName} not found`)
|
||||||
|
|
||||||
|
const storage = STORAGE_FOR_FORMAT[format]
|
||||||
|
const csv = format === 'csv'
|
||||||
|
await this.glue.send(new CreateTableCommand({
|
||||||
|
DatabaseName: databaseName,
|
||||||
|
TableInput: {
|
||||||
|
Name: name,
|
||||||
|
TableType: 'EXTERNAL_TABLE',
|
||||||
|
...(description ? {Description: description} : {}),
|
||||||
|
Parameters: {
|
||||||
|
classification: format,
|
||||||
|
EXTERNAL: 'TRUE',
|
||||||
|
...(csv && input.headerRow ? {'skip.header.line.count': '1'} : {}),
|
||||||
|
},
|
||||||
|
StorageDescriptor: {
|
||||||
|
Columns: columns.map((column) => ({Name: column.name, Type: column.type})),
|
||||||
|
// Athena reads every file under the prefix, so the location is a directory.
|
||||||
|
Location: location.endsWith('/') ? location : `${location}/`,
|
||||||
|
InputFormat: storage.InputFormat,
|
||||||
|
OutputFormat: storage.OutputFormat,
|
||||||
|
SerdeInfo: {
|
||||||
|
SerializationLibrary: storage.SerializationLibrary,
|
||||||
|
...(csv ? {Parameters: {'field.delim': delimiter}} : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
return this.detail(databaseName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCatalogTable(databaseName: string, tableName: string): Promise<CloudResource> {
|
||||||
|
await this.glue.send(new DeleteTableCommand({DatabaseName: databaseName, Name: tableName}))
|
||||||
|
return this.detail(databaseName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async database(name: string): Promise<Database | null> {
|
||||||
|
try {
|
||||||
|
return (await this.glue.send(new GetDatabaseCommand({Name: name}))).Database ?? null
|
||||||
|
} catch (error) {
|
||||||
|
if (errorName(error) === 'EntityNotFoundException') return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async detail(name: string): Promise<CloudResource> {
|
||||||
|
const detail = await this.get(name)
|
||||||
|
if (!detail) throw new NotFoundError(`Database ${name} not found`)
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tables(databaseName: string): Promise<Table[]> {
|
||||||
|
const found: Table[] = []
|
||||||
|
let next: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.glue.send(new GetTablesCommand({DatabaseName: databaseName, MaxResults: 100, ...(next ? {NextToken: next} : {})}))
|
||||||
|
found.push(...(res.TableList ?? []))
|
||||||
|
next = res.NextToken
|
||||||
|
} while (next)
|
||||||
|
return found.sort((a, b) => (a.Name ?? '').localeCompare(b.Name ?? ''))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResource(database: Database, detail: {tableCount: number; tables?: Table[]}): CloudResource {
|
||||||
|
const name = database.Name ?? ''
|
||||||
|
return {
|
||||||
|
id: name,
|
||||||
|
name,
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'datacatalog',
|
||||||
|
type: 'glueDatabase',
|
||||||
|
region: null,
|
||||||
|
createdAt: database.CreateTime ? database.CreateTime.toISOString() : null,
|
||||||
|
metadata: {
|
||||||
|
description: database.Description || null,
|
||||||
|
locationUri: database.LocationUri || null,
|
||||||
|
catalogId: database.CatalogId ?? null,
|
||||||
|
tableCount: detail.tableCount,
|
||||||
|
...(detail.tables ? {tables: detail.tables.map(describeTable)} : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the panel shows for a table: its files, how they are read, and its columns. */
|
||||||
|
export function describeTable(table: Table) {
|
||||||
|
const storage: StorageDescriptor = table.StorageDescriptor ?? {}
|
||||||
|
const format = tableFormat(table)
|
||||||
|
return {
|
||||||
|
name: table.Name ?? '',
|
||||||
|
description: table.Description || null,
|
||||||
|
format,
|
||||||
|
location: storage.Location ?? null,
|
||||||
|
delimiter: format === 'csv' ? storage.SerdeInfo?.Parameters?.['field.delim'] ?? ',' : null,
|
||||||
|
headerRow: table.Parameters?.['skip.header.line.count'] === '1',
|
||||||
|
columns: (storage.Columns ?? []).map((column) => ({name: column.Name ?? '', type: column.Type ?? ''})),
|
||||||
|
partitionKeys: (table.PartitionKeys ?? []).map((column) => ({name: column.Name ?? '', type: column.Type ?? ''})),
|
||||||
|
tableType: table.TableType ?? null,
|
||||||
|
createdAt: table.CreateTime ? table.CreateTime.toISOString() : null,
|
||||||
|
updatedAt: table.UpdateTime ? table.UpdateTime.toISOString() : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same order the runtime's Athena engine checks a table's storage in. */
|
||||||
|
function tableFormat(table: Table): string {
|
||||||
|
const classification = table.Parameters?.classification?.toLowerCase()
|
||||||
|
if (classification === 'iceberg' || table.Parameters?.table_type?.toLowerCase() === 'iceberg') return 'iceberg'
|
||||||
|
const storage = table.StorageDescriptor
|
||||||
|
const hints = `${storage?.InputFormat ?? ''} ${storage?.SerdeInfo?.SerializationLibrary ?? ''}`.toLowerCase()
|
||||||
|
if (hints.includes('parquet')) return 'parquet'
|
||||||
|
if (hints.includes('json')) return 'json'
|
||||||
|
return classification || 'csv'
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableColumns(value: unknown): Array<{name: string; type: string}> {
|
||||||
|
if (!Array.isArray(value) || value.length === 0) throw new ValidationError('columns must list at least one column')
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return value.map((column, index) => {
|
||||||
|
const entry = typeof column === 'object' && column !== null ? column as Record<string, unknown> : {}
|
||||||
|
const name = catalogName(entry.name, `columns[${index}].name`)
|
||||||
|
if (seen.has(name)) throw new ValidationError(`column ${name} is listed twice`)
|
||||||
|
seen.add(name)
|
||||||
|
const type = text(entry.type).toLowerCase()
|
||||||
|
if (!(CATALOG_COLUMN_TYPES as readonly string[]).includes(type) && !DECIMAL_TYPE.test(type)) {
|
||||||
|
throw new ValidationError(`columns[${index}].type must be one of ${CATALOG_COLUMN_TYPES.join(', ')}`)
|
||||||
|
}
|
||||||
|
return {name, type}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalogName(value: unknown, field: string): string {
|
||||||
|
const name = text(value)
|
||||||
|
if (!new RegExp(CATALOG_NAME_PATTERN).test(name)) throw new ValidationError(`${field} must be lowercase letters, numbers and underscores, starting with a letter or underscore`)
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import {describe, expect, test} from 'bun:test'
|
||||||
|
import {
|
||||||
|
CreateAccessPointCommand,
|
||||||
|
CreateFileSystemCommand,
|
||||||
|
CreateMountTargetCommand,
|
||||||
|
DeleteAccessPointCommand,
|
||||||
|
DeleteFileSystemCommand,
|
||||||
|
DeleteMountTargetCommand,
|
||||||
|
DescribeAccessPointsCommand,
|
||||||
|
DescribeFileSystemsCommand,
|
||||||
|
DescribeMountTargetsCommand,
|
||||||
|
type EFSClient,
|
||||||
|
} from '@aws-sdk/client-efs'
|
||||||
|
import {DescribeSecurityGroupsCommand, DescribeSubnetsCommand, type EC2Client} from '@aws-sdk/client-ec2'
|
||||||
|
import {GetFunctionConfigurationCommand, type LambdaClient, ListFunctionsCommand, UpdateFunctionConfigurationCommand} from '@aws-sdk/client-lambda'
|
||||||
|
import {AwsFileSystemsAdapter} from './AwsFileSystemsAdapter'
|
||||||
|
import {ConflictError, NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
|
||||||
|
type SendResult = Record<string, unknown>
|
||||||
|
|
||||||
|
function stub(handler: (command: object) => SendResult | undefined) {
|
||||||
|
const sent: object[] = []
|
||||||
|
const client = {
|
||||||
|
async send(command: object) {
|
||||||
|
sent.push(command)
|
||||||
|
return handler(command) ?? {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {client, sent}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessPointArn = 'arn:aws:elasticfilesystem:us-east-1:000000000000:access-point/fsap-1'
|
||||||
|
const fileSystem = {
|
||||||
|
FileSystemId: 'fs-1',
|
||||||
|
FileSystemArn: 'arn:aws:elasticfilesystem:us-east-1:000000000000:file-system/fs-1',
|
||||||
|
Name: 'shared',
|
||||||
|
LifeCycleState: 'available',
|
||||||
|
NumberOfMountTargets: 1,
|
||||||
|
PerformanceMode: 'generalPurpose',
|
||||||
|
ThroughputMode: 'bursting',
|
||||||
|
Encrypted: true,
|
||||||
|
CreationTime: new Date('2026-10-25T17:09:01Z'),
|
||||||
|
}
|
||||||
|
const mountTarget = {MountTargetId: 'fsmt-1', FileSystemId: 'fs-1', SubnetId: 'subnet-a', AvailabilityZoneName: 'us-east-1a', IpAddress: '10.0.0.10', VpcId: 'vpc-1', LifeCycleState: 'available'}
|
||||||
|
const accessPoint = {
|
||||||
|
AccessPointId: 'fsap-1', AccessPointArn: accessPointArn, FileSystemId: 'fs-1', Name: 'app',
|
||||||
|
PosixUser: {Uid: 1000, Gid: 1000}, RootDirectory: {Path: '/app', CreationInfo: {OwnerUid: 1000, OwnerGid: 1000, Permissions: '755'}}, LifeCycleState: 'available',
|
||||||
|
}
|
||||||
|
const writer = {FunctionName: 'writer', FileSystemConfigs: [{Arn: accessPointArn, LocalMountPath: '/mnt/share'}]}
|
||||||
|
|
||||||
|
function adapterWith({efs = () => undefined, lambda = () => undefined, functions = [writer]}: {
|
||||||
|
efs?: (command: object) => SendResult | undefined
|
||||||
|
lambda?: (command: object) => SendResult | undefined
|
||||||
|
functions?: object[]
|
||||||
|
} = {}) {
|
||||||
|
const efsStub = stub((command) => efs(command) ?? (
|
||||||
|
command instanceof DescribeFileSystemsCommand ? {FileSystems: [fileSystem]}
|
||||||
|
: command instanceof DescribeMountTargetsCommand ? {MountTargets: [mountTarget]}
|
||||||
|
: command instanceof DescribeAccessPointsCommand ? {AccessPoints: [accessPoint]}
|
||||||
|
: command instanceof CreateFileSystemCommand ? {FileSystemId: 'fs-1'}
|
||||||
|
: undefined))
|
||||||
|
const lambdaStub = stub((command) => lambda(command) ?? (command instanceof ListFunctionsCommand ? {Functions: functions} : undefined))
|
||||||
|
const ec2Stub = stub((command) => command instanceof DescribeSubnetsCommand
|
||||||
|
? {Subnets: [{SubnetId: 'subnet-b', AvailabilityZone: 'us-east-1b'}, {SubnetId: 'subnet-a', AvailabilityZone: 'us-east-1a'}, {SubnetId: 'subnet-a2', AvailabilityZone: 'us-east-1a'}]}
|
||||||
|
: command instanceof DescribeSecurityGroupsCommand ? {SecurityGroups: [{GroupId: 'sg-default'}]}
|
||||||
|
: undefined)
|
||||||
|
return {
|
||||||
|
efsSent: efsStub.sent,
|
||||||
|
lambdaSent: lambdaStub.sent,
|
||||||
|
adapter: new AwsFileSystemsAdapter(efsStub.client as unknown as EFSClient, lambdaStub.client as unknown as LambdaClient, ec2Stub.client as unknown as EC2Client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AwsFileSystemsAdapter', () => {
|
||||||
|
test('lists file systems across pages and searches by name or id', async () => {
|
||||||
|
const {adapter} = adapterWith({
|
||||||
|
efs: (command) => command instanceof DescribeFileSystemsCommand
|
||||||
|
? command.input.Marker ? {FileSystems: [{...fileSystem, FileSystemId: 'fs-2', Name: undefined, Tags: [{Key: 'Name', Value: 'logs'}]}]} : {FileSystems: [fileSystem], NextMarker: 'm'}
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const listed = await adapter.list()
|
||||||
|
expect(listed.map((fs) => [fs.id, fs.name, fs.status, fs.metadata.encrypted])).toEqual([['fs-1', 'shared', 'available', true], ['fs-2', 'logs', 'available', true]])
|
||||||
|
expect((await adapter.list({search: 'fs-2'})).map((fs) => fs.name)).toEqual(['logs'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describes mount targets, access points and the functions that mount it', async () => {
|
||||||
|
const {adapter} = adapterWith({functions: [writer, {FunctionName: 'other'}]})
|
||||||
|
const detail = await adapter.get('fs-1')
|
||||||
|
|
||||||
|
expect(detail?.metadata.mountTargets).toEqual([{id: 'fsmt-1', subnetId: 'subnet-a', availabilityZone: 'us-east-1a', ipAddress: '10.0.0.10', vpcId: 'vpc-1', state: 'available'}])
|
||||||
|
expect(detail?.metadata.accessPoints).toEqual([{id: 'fsap-1', arn: accessPointArn, name: 'app', path: '/app', uid: 1000, gid: 1000, permissions: '755', state: 'available'}])
|
||||||
|
expect(detail?.metadata.functions).toEqual([{name: 'writer', accessPointId: 'fsap-1', mountPath: '/mnt/share'}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null for a file system that does not exist', async () => {
|
||||||
|
const missing = Object.assign(new Error('not found'), {name: 'FileSystemNotFound'})
|
||||||
|
const {adapter} = adapterWith({efs: (command) => { if (command instanceof DescribeFileSystemsCommand) throw missing; return undefined }})
|
||||||
|
expect(await adapter.get('fs-nope')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates an encrypted file system with one mount target per default zone', async () => {
|
||||||
|
const {adapter, efsSent} = adapterWith()
|
||||||
|
await adapter.create({values: {name: 'shared', performanceMode: 'generalPurpose', throughputMode: 'elastic', encrypted: 'true', mountTargets: 'default'}})
|
||||||
|
|
||||||
|
const create = efsSent.find((command) => command instanceof CreateFileSystemCommand) as CreateFileSystemCommand
|
||||||
|
expect(create.input).toMatchObject({PerformanceMode: 'generalPurpose', ThroughputMode: 'elastic', Encrypted: true, Tags: [{Key: 'Name', Value: 'shared'}]})
|
||||||
|
const mounts = efsSent.filter((command) => command instanceof CreateMountTargetCommand) as CreateMountTargetCommand[]
|
||||||
|
expect(mounts.map((command) => command.input.SubnetId)).toEqual(['subnet-a', 'subnet-b'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('can create a file system without mount targets, and refuses unknown modes', async () => {
|
||||||
|
const {adapter, efsSent} = adapterWith()
|
||||||
|
await adapter.create({values: {mountTargets: 'none', encrypted: 'false'}})
|
||||||
|
expect(efsSent.some((command) => command instanceof CreateMountTargetCommand)).toBe(false)
|
||||||
|
expect((efsSent.find((command) => command instanceof CreateFileSystemCommand) as CreateFileSystemCommand).input.Encrypted).toBe(false)
|
||||||
|
|
||||||
|
await expect(adapter.create({values: {throughputMode: 'provisioned'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deletes access points and mount targets before the file system', async () => {
|
||||||
|
const {adapter, efsSent} = adapterWith({functions: []})
|
||||||
|
await adapter.delete('fs-1')
|
||||||
|
|
||||||
|
const order = efsSent.filter((command) => command instanceof DeleteAccessPointCommand || command instanceof DeleteMountTargetCommand || command instanceof DeleteFileSystemCommand)
|
||||||
|
.map((command) => command.constructor.name)
|
||||||
|
expect(order).toEqual(['DeleteAccessPointCommand', 'DeleteMountTargetCommand', 'DeleteFileSystemCommand'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses to delete a file system or access point a function still mounts', async () => {
|
||||||
|
const {adapter, efsSent} = adapterWith()
|
||||||
|
await expect(adapter.delete('fs-1')).rejects.toBeInstanceOf(ConflictError)
|
||||||
|
await expect(adapter.deleteAccessPoint('fs-1', 'fsap-1')).rejects.toBeInstanceOf(ConflictError)
|
||||||
|
expect(efsSent.some((command) => command instanceof DeleteFileSystemCommand || command instanceof DeleteAccessPointCommand)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates an access point whose root EFS makes for its POSIX user', async () => {
|
||||||
|
const {adapter, efsSent} = adapterWith()
|
||||||
|
await adapter.createAccessPoint('fs-1', {name: 'app', path: '/app', uid: 1000, gid: 1000, permissions: '750'})
|
||||||
|
|
||||||
|
const create = efsSent.find((command) => command instanceof CreateAccessPointCommand) as CreateAccessPointCommand
|
||||||
|
expect(create.input).toMatchObject({
|
||||||
|
FileSystemId: 'fs-1',
|
||||||
|
PosixUser: {Uid: 1000, Gid: 1000},
|
||||||
|
RootDirectory: {Path: '/app', CreationInfo: {OwnerUid: 1000, OwnerGid: 1000, Permissions: '750'}},
|
||||||
|
Tags: [{Key: 'Name', Value: 'app'}],
|
||||||
|
})
|
||||||
|
await expect(adapter.createAccessPoint('fs-1', {path: 'app', uid: 1000, gid: 1000, permissions: '755'})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.createAccessPoint('fs-1', {path: '/app', uid: -1, gid: 1000, permissions: '755'})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.createAccessPoint('fs-1', {path: '/app', uid: 1000, gid: 1000, permissions: '999'})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('adds and removes mount targets that belong to the file system', async () => {
|
||||||
|
const {adapter, efsSent} = adapterWith()
|
||||||
|
await adapter.createMountTarget('fs-1', 'subnet-c')
|
||||||
|
expect((efsSent.find((command) => command instanceof CreateMountTargetCommand) as CreateMountTargetCommand).input).toEqual({FileSystemId: 'fs-1', SubnetId: 'subnet-c'})
|
||||||
|
|
||||||
|
await adapter.deleteMountTarget('fs-1', 'fsmt-1')
|
||||||
|
expect(efsSent.some((command) => command instanceof DeleteMountTargetCommand)).toBe(true)
|
||||||
|
await expect(adapter.deleteMountTarget('fs-1', 'fsmt-other')).rejects.toBeInstanceOf(NotFoundError)
|
||||||
|
await expect(adapter.createMountTarget('fs-1', 'not-a-subnet')).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('attaching puts a function without a VPC in the mount target subnets', async () => {
|
||||||
|
const {adapter, lambdaSent} = adapterWith({lambda: (command) => command instanceof GetFunctionConfigurationCommand ? {FunctionName: 'reader'} : undefined})
|
||||||
|
await adapter.attachFileSystemToFunction('fs-1', {accessPointId: 'fsap-1', functionName: 'reader', mountPath: '/mnt/share'})
|
||||||
|
|
||||||
|
const update = lambdaSent.find((command) => command instanceof UpdateFunctionConfigurationCommand) as UpdateFunctionConfigurationCommand
|
||||||
|
expect(update.input).toEqual({
|
||||||
|
FunctionName: 'reader',
|
||||||
|
FileSystemConfigs: [{Arn: accessPointArn, LocalMountPath: '/mnt/share'}],
|
||||||
|
VpcConfig: {SubnetIds: ['subnet-a'], SecurityGroupIds: ['sg-default']},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('attaching keeps a function that is already in a VPC where it is', async () => {
|
||||||
|
const {adapter, lambdaSent} = adapterWith({lambda: (command) => command instanceof GetFunctionConfigurationCommand ? {FunctionName: 'reader', VpcConfig: {SubnetIds: ['subnet-z']}} : undefined})
|
||||||
|
await adapter.attachFileSystemToFunction('fs-1', {accessPointId: 'fsap-1', functionName: 'reader', mountPath: '/mnt/share'})
|
||||||
|
|
||||||
|
const update = lambdaSent.find((command) => command instanceof UpdateFunctionConfigurationCommand) as UpdateFunctionConfigurationCommand
|
||||||
|
expect(update.input.VpcConfig).toBeUndefined()
|
||||||
|
await expect(adapter.attachFileSystemToFunction('fs-1', {accessPointId: 'fsap-1', functionName: 'reader', mountPath: '/data'})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.attachFileSystemToFunction('fs-1', {accessPointId: 'fsap-x', functionName: 'reader', mountPath: '/mnt/share'})).rejects.toBeInstanceOf(NotFoundError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cannot attach before the file system has a mount target', async () => {
|
||||||
|
const {adapter} = adapterWith({
|
||||||
|
efs: (command) => command instanceof DescribeMountTargetsCommand ? {MountTargets: []} : undefined,
|
||||||
|
lambda: (command) => command instanceof GetFunctionConfigurationCommand ? {FunctionName: 'reader'} : undefined,
|
||||||
|
})
|
||||||
|
await expect(adapter.attachFileSystemToFunction('fs-1', {accessPointId: 'fsap-1', functionName: 'reader', mountPath: '/mnt/share'})).rejects.toThrow(/Add a mount target first/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detaching clears the function file systems', async () => {
|
||||||
|
const {adapter, lambdaSent} = adapterWith()
|
||||||
|
await adapter.detachFileSystemFromFunction('fs-1', 'writer')
|
||||||
|
expect((lambdaSent.find((command) => command instanceof UpdateFunctionConfigurationCommand) as UpdateFunctionConfigurationCommand).input).toEqual({FunctionName: 'writer', FileSystemConfigs: []})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import {
|
||||||
|
type AccessPointDescription,
|
||||||
|
CreateAccessPointCommand,
|
||||||
|
CreateFileSystemCommand,
|
||||||
|
CreateMountTargetCommand,
|
||||||
|
DeleteAccessPointCommand,
|
||||||
|
DeleteFileSystemCommand,
|
||||||
|
DeleteMountTargetCommand,
|
||||||
|
DescribeAccessPointsCommand,
|
||||||
|
DescribeFileSystemsCommand,
|
||||||
|
DescribeMountTargetsCommand,
|
||||||
|
type EFSClient,
|
||||||
|
type FileSystemDescription,
|
||||||
|
type MountTargetDescription,
|
||||||
|
} from '@aws-sdk/client-efs'
|
||||||
|
import {DescribeSecurityGroupsCommand, DescribeSubnetsCommand, type EC2Client} from '@aws-sdk/client-ec2'
|
||||||
|
import {type FunctionConfiguration, GetFunctionConfigurationCommand, type LambdaClient, ListFunctionsCommand, UpdateFunctionConfigurationCommand} from '@aws-sdk/client-lambda'
|
||||||
|
import {ConflictError, NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
import {
|
||||||
|
ACCESS_POINT_PATH_PATTERN,
|
||||||
|
awsFileSystemsSchema,
|
||||||
|
FILE_SYSTEM_NAME_PATTERN,
|
||||||
|
FILE_SYSTEM_PERFORMANCE_MODES,
|
||||||
|
FILE_SYSTEM_THROUGHPUT_MODES,
|
||||||
|
LAMBDA_MOUNT_PATH_PATTERN,
|
||||||
|
} from '../cloud-spi/fileSystemsSchema'
|
||||||
|
import type {
|
||||||
|
AccessPointInput,
|
||||||
|
CloudResource,
|
||||||
|
CloudServiceAdapter,
|
||||||
|
CreateResourceInput,
|
||||||
|
FileSystemAttachmentInput,
|
||||||
|
ResourceQuery,
|
||||||
|
ServiceSchema,
|
||||||
|
} from '../cloud-spi/types'
|
||||||
|
|
||||||
|
/** POSIX ids EFS accepts for an access point's user and directory owner. */
|
||||||
|
const MAX_POSIX_ID = 4_294_967_295
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talks to Amazon EFS, verified against Floci core (community, `latest`).
|
||||||
|
*
|
||||||
|
* Notes from probing the runtime:
|
||||||
|
* - Mounts are real: a Lambda function given an access point gets a Docker
|
||||||
|
* volume at its mount path, so two functions on one access point share files.
|
||||||
|
* - Lambda refuses a file system without a VPC config, as AWS does, so
|
||||||
|
* attaching puts an un-networked function in its file system's subnets.
|
||||||
|
* - Metered size is always reported as 0 bytes, so the table does not show it.
|
||||||
|
* - DeleteFileSystem refuses while mount targets exist, so delete removes them
|
||||||
|
* (and the access points) first, like the AWS console.
|
||||||
|
*/
|
||||||
|
export class AwsFileSystemsAdapter implements CloudServiceAdapter {
|
||||||
|
readonly cloud = 'aws' as const
|
||||||
|
readonly service = 'filesystems' as const
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly efs: EFSClient,
|
||||||
|
private readonly lambda: LambdaClient,
|
||||||
|
private readonly ec2: EC2Client,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
schema(): ServiceSchema {
|
||||||
|
return awsFileSystemsSchema()
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||||
|
const found: FileSystemDescription[] = []
|
||||||
|
let marker: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.efs.send(new DescribeFileSystemsCommand({MaxItems: 100, ...(marker ? {Marker: marker} : {})}))
|
||||||
|
found.push(...(res.FileSystems ?? []))
|
||||||
|
marker = res.NextMarker
|
||||||
|
} while (marker)
|
||||||
|
|
||||||
|
const search = query.search?.trim().toLowerCase()
|
||||||
|
return found.map((fileSystem) => toResource(fileSystem)).filter((fileSystem) => !search
|
||||||
|
|| fileSystem.name.toLowerCase().includes(search)
|
||||||
|
|| fileSystem.id.toLowerCase().includes(search))
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<CloudResource | null> {
|
||||||
|
const fileSystem = await this.describe(id)
|
||||||
|
if (!fileSystem) return null
|
||||||
|
const [mountTargets, accessPoints] = await Promise.all([this.mountTargets(id), this.accessPoints(id)])
|
||||||
|
const accessPointArns = new Set(accessPoints.map((accessPoint) => accessPoint.AccessPointArn))
|
||||||
|
const functions = (await this.functions()).filter((fn) => (fn.FileSystemConfigs ?? []).some((config) => accessPointArns.has(config.Arn)))
|
||||||
|
return toResource(fileSystem, {mountTargets, accessPoints, functions})
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||||
|
const values = input.values
|
||||||
|
const name = text(values.name)
|
||||||
|
if (name && !new RegExp(FILE_SYSTEM_NAME_PATTERN).test(name)) throw new ValidationError('name must be up to 256 characters')
|
||||||
|
const performanceMode = oneOf(text(values.performanceMode) || 'generalPurpose', FILE_SYSTEM_PERFORMANCE_MODES, 'performanceMode')
|
||||||
|
const throughputMode = oneOf(text(values.throughputMode) || 'bursting', FILE_SYSTEM_THROUGHPUT_MODES, 'throughputMode')
|
||||||
|
|
||||||
|
const created = await this.efs.send(new CreateFileSystemCommand({
|
||||||
|
CreationToken: crypto.randomUUID(),
|
||||||
|
PerformanceMode: performanceMode,
|
||||||
|
ThroughputMode: throughputMode,
|
||||||
|
Encrypted: values.encrypted !== 'false',
|
||||||
|
...(name ? {Tags: [{Key: 'Name', Value: name}]} : {}),
|
||||||
|
}))
|
||||||
|
const fileSystemId = created.FileSystemId
|
||||||
|
if (!fileSystemId) throw new RuntimeError('EFS did not return the new file system id')
|
||||||
|
|
||||||
|
if (text(values.mountTargets) !== 'none') {
|
||||||
|
for (const subnetId of await this.defaultSubnets()) {
|
||||||
|
await this.efs.send(new CreateMountTargetCommand({FileSystemId: fileSystemId, SubnetId: subnetId}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = await this.get(fileSystemId)
|
||||||
|
if (!detail) throw new RuntimeError(`EFS did not return the new file system ${fileSystemId}`)
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
const accessPoints = await this.accessPoints(id)
|
||||||
|
const accessPointArns = new Set(accessPoints.map((accessPoint) => accessPoint.AccessPointArn))
|
||||||
|
const users = (await this.functions()).filter((fn) => (fn.FileSystemConfigs ?? []).some((config) => accessPointArns.has(config.Arn)))
|
||||||
|
if (users.length > 0) {
|
||||||
|
throw new ConflictError(`Detach the file system from ${users.map((fn) => fn.FunctionName).join(', ')} before deleting it`)
|
||||||
|
}
|
||||||
|
for (const accessPoint of accessPoints) {
|
||||||
|
await this.efs.send(new DeleteAccessPointCommand({AccessPointId: accessPoint.AccessPointId}))
|
||||||
|
}
|
||||||
|
for (const mountTarget of await this.mountTargets(id)) {
|
||||||
|
await this.efs.send(new DeleteMountTargetCommand({MountTargetId: mountTarget.MountTargetId}))
|
||||||
|
}
|
||||||
|
await this.efs.send(new DeleteFileSystemCommand({FileSystemId: id}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMountTarget(fileSystemId: string, subnetId: string): Promise<CloudResource> {
|
||||||
|
const subnet = subnetId.trim()
|
||||||
|
if (!/^subnet-[a-zA-Z0-9-]+$/.test(subnet)) throw new ValidationError('subnetId must be a subnet ID such as subnet-0abc')
|
||||||
|
await this.requireFileSystem(fileSystemId)
|
||||||
|
await this.efs.send(new CreateMountTargetCommand({FileSystemId: fileSystemId, SubnetId: subnet}))
|
||||||
|
return this.detail(fileSystemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMountTarget(fileSystemId: string, mountTargetId: string): Promise<CloudResource> {
|
||||||
|
const owned = (await this.mountTargets(fileSystemId)).some((mountTarget) => mountTarget.MountTargetId === mountTargetId)
|
||||||
|
if (!owned) throw new NotFoundError(`Mount target ${mountTargetId} is not part of ${fileSystemId}`)
|
||||||
|
await this.efs.send(new DeleteMountTargetCommand({MountTargetId: mountTargetId}))
|
||||||
|
return this.detail(fileSystemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAccessPoint(fileSystemId: string, input: AccessPointInput): Promise<CloudResource> {
|
||||||
|
const path = text(input.path)
|
||||||
|
if (!new RegExp(ACCESS_POINT_PATH_PATTERN).test(path)) throw new ValidationError('path must start with / and be up to 100 characters')
|
||||||
|
const uid = posixId(input.uid, 'uid')
|
||||||
|
const gid = posixId(input.gid, 'gid')
|
||||||
|
const permissions = text(input.permissions)
|
||||||
|
if (!/^[0-7]{3,4}$/.test(permissions)) throw new ValidationError('permissions must be octal, such as 755')
|
||||||
|
const name = text(input.name)
|
||||||
|
await this.requireFileSystem(fileSystemId)
|
||||||
|
|
||||||
|
await this.efs.send(new CreateAccessPointCommand({
|
||||||
|
ClientToken: crypto.randomUUID(),
|
||||||
|
FileSystemId: fileSystemId,
|
||||||
|
PosixUser: {Uid: uid, Gid: gid},
|
||||||
|
RootDirectory: {Path: path, CreationInfo: {OwnerUid: uid, OwnerGid: gid, Permissions: permissions}},
|
||||||
|
...(name ? {Tags: [{Key: 'Name', Value: name}]} : {}),
|
||||||
|
}))
|
||||||
|
return this.detail(fileSystemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAccessPoint(fileSystemId: string, accessPointId: string): Promise<CloudResource> {
|
||||||
|
const accessPoint = (await this.accessPoints(fileSystemId)).find((candidate) => candidate.AccessPointId === accessPointId)
|
||||||
|
if (!accessPoint) throw new NotFoundError(`Access point ${accessPointId} is not part of ${fileSystemId}`)
|
||||||
|
const users = (await this.functions()).filter((fn) => (fn.FileSystemConfigs ?? []).some((config) => config.Arn === accessPoint.AccessPointArn))
|
||||||
|
if (users.length > 0) {
|
||||||
|
throw new ConflictError(`Detach ${users.map((fn) => fn.FunctionName).join(', ')} from this access point before deleting it`)
|
||||||
|
}
|
||||||
|
await this.efs.send(new DeleteAccessPointCommand({AccessPointId: accessPointId}))
|
||||||
|
return this.detail(fileSystemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async attachFileSystemToFunction(fileSystemId: string, input: FileSystemAttachmentInput): Promise<CloudResource> {
|
||||||
|
const mountPath = text(input.mountPath)
|
||||||
|
if (!new RegExp(LAMBDA_MOUNT_PATH_PATTERN).test(mountPath)) throw new ValidationError('mountPath must be under /mnt, such as /mnt/data')
|
||||||
|
const functionName = text(input.functionName)
|
||||||
|
if (!functionName) throw new ValidationError('functionName is required')
|
||||||
|
const accessPoint = (await this.accessPoints(fileSystemId)).find((candidate) => candidate.AccessPointId === input.accessPointId)
|
||||||
|
if (!accessPoint?.AccessPointArn) throw new NotFoundError(`Access point ${input.accessPointId} is not part of ${fileSystemId}`)
|
||||||
|
|
||||||
|
const fn = await this.lambda.send(new GetFunctionConfigurationCommand({FunctionName: functionName}))
|
||||||
|
const inVpc = (fn.VpcConfig?.SubnetIds ?? []).length > 0
|
||||||
|
const vpcConfig = inVpc ? undefined : await this.vpcConfigFor(fileSystemId)
|
||||||
|
|
||||||
|
await this.lambda.send(new UpdateFunctionConfigurationCommand({
|
||||||
|
FunctionName: functionName,
|
||||||
|
FileSystemConfigs: [{Arn: accessPoint.AccessPointArn, LocalMountPath: mountPath}],
|
||||||
|
...(vpcConfig ? {VpcConfig: vpcConfig} : {}),
|
||||||
|
}))
|
||||||
|
return this.detail(fileSystemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async detachFileSystemFromFunction(fileSystemId: string, functionName: string): Promise<CloudResource> {
|
||||||
|
await this.lambda.send(new UpdateFunctionConfigurationCommand({FunctionName: functionName, FileSystemConfigs: []}))
|
||||||
|
return this.detail(fileSystemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async describe(id: string): Promise<FileSystemDescription | null> {
|
||||||
|
try {
|
||||||
|
return (await this.efs.send(new DescribeFileSystemsCommand({FileSystemId: id}))).FileSystems?.[0] ?? null
|
||||||
|
} catch (error) {
|
||||||
|
if (errorName(error) === 'FileSystemNotFound') return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireFileSystem(id: string): Promise<void> {
|
||||||
|
if (!await this.describe(id)) throw new NotFoundError(`File system ${id} not found`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async detail(id: string): Promise<CloudResource> {
|
||||||
|
const detail = await this.get(id)
|
||||||
|
if (!detail) throw new NotFoundError(`File system ${id} not found`)
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mountTargets(fileSystemId: string): Promise<MountTargetDescription[]> {
|
||||||
|
return (await this.efs.send(new DescribeMountTargetsCommand({FileSystemId: fileSystemId}))).MountTargets ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private async accessPoints(fileSystemId: string): Promise<AccessPointDescription[]> {
|
||||||
|
const found: AccessPointDescription[] = []
|
||||||
|
let next: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.efs.send(new DescribeAccessPointsCommand({FileSystemId: fileSystemId, ...(next ? {NextToken: next} : {})}))
|
||||||
|
found.push(...(res.AccessPoints ?? []))
|
||||||
|
next = res.NextToken
|
||||||
|
} while (next)
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
private async functions(): Promise<FunctionConfiguration[]> {
|
||||||
|
const found: FunctionConfiguration[] = []
|
||||||
|
let marker: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.lambda.send(new ListFunctionsCommand({MaxItems: 50, ...(marker ? {Marker: marker} : {})}))
|
||||||
|
found.push(...(res.Functions ?? []))
|
||||||
|
marker = res.NextMarker
|
||||||
|
} while (marker)
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Puts a function in the subnets its file system is reachable from, with the VPC's default security group. */
|
||||||
|
private async vpcConfigFor(fileSystemId: string): Promise<{SubnetIds: string[]; SecurityGroupIds: string[]}> {
|
||||||
|
const mountTargets = await this.mountTargets(fileSystemId)
|
||||||
|
const subnetIds = mountTargets.map((mountTarget) => mountTarget.SubnetId).filter((id): id is string => Boolean(id))
|
||||||
|
const vpcId = mountTargets.find((mountTarget) => mountTarget.VpcId)?.VpcId
|
||||||
|
if (subnetIds.length === 0 || !vpcId) throw new ValidationError('Add a mount target first: Lambda reaches a file system through one in its subnet')
|
||||||
|
const groups = await this.ec2.send(new DescribeSecurityGroupsCommand({Filters: [{Name: 'vpc-id', Values: [vpcId]}, {Name: 'group-name', Values: ['default']}]}))
|
||||||
|
const securityGroupId = groups.SecurityGroups?.[0]?.GroupId
|
||||||
|
if (!securityGroupId) throw new ValidationError(`The VPC ${vpcId} has no default security group to put the function in`)
|
||||||
|
return {SubnetIds: [...new Set(subnetIds)].sort(), SecurityGroupIds: [securityGroupId]}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async defaultSubnets(): Promise<string[]> {
|
||||||
|
const res = await this.ec2.send(new DescribeSubnetsCommand({Filters: [{Name: 'default-for-az', Values: ['true']}]}))
|
||||||
|
const byZone = new Map<string, string>()
|
||||||
|
for (const subnet of res.Subnets ?? []) {
|
||||||
|
if (subnet.SubnetId && !byZone.has(subnet.AvailabilityZone ?? subnet.SubnetId)) byZone.set(subnet.AvailabilityZone ?? subnet.SubnetId, subnet.SubnetId)
|
||||||
|
}
|
||||||
|
if (byZone.size === 0) throw new ValidationError('There is no default VPC to put mount targets in; create the file system without them and add one later')
|
||||||
|
return [...byZone.values()].sort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileSystemDetail = {
|
||||||
|
mountTargets: MountTargetDescription[]
|
||||||
|
accessPoints: AccessPointDescription[]
|
||||||
|
functions: FunctionConfiguration[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResource(fileSystem: FileSystemDescription, detail?: FileSystemDetail): CloudResource {
|
||||||
|
const id = fileSystem.FileSystemId ?? ''
|
||||||
|
const name = fileSystem.Name || tag(fileSystem.Tags, 'Name') || id
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'filesystems',
|
||||||
|
type: 'fileSystem',
|
||||||
|
region: fileSystem.FileSystemArn?.split(':')[3] || null,
|
||||||
|
createdAt: fileSystem.CreationTime ? fileSystem.CreationTime.toISOString() : null,
|
||||||
|
status: fileSystem.LifeCycleState ?? null,
|
||||||
|
metadata: {
|
||||||
|
fileSystemId: id,
|
||||||
|
arn: fileSystem.FileSystemArn ?? null,
|
||||||
|
numberOfMountTargets: fileSystem.NumberOfMountTargets ?? 0,
|
||||||
|
performanceMode: fileSystem.PerformanceMode ?? null,
|
||||||
|
throughputMode: fileSystem.ThroughputMode ?? null,
|
||||||
|
encrypted: fileSystem.Encrypted === true,
|
||||||
|
...(detail ? {
|
||||||
|
mountTargets: detail.mountTargets.map((mountTarget) => ({
|
||||||
|
id: mountTarget.MountTargetId ?? '',
|
||||||
|
subnetId: mountTarget.SubnetId ?? null,
|
||||||
|
availabilityZone: mountTarget.AvailabilityZoneName ?? null,
|
||||||
|
ipAddress: mountTarget.IpAddress ?? null,
|
||||||
|
vpcId: mountTarget.VpcId ?? null,
|
||||||
|
state: mountTarget.LifeCycleState ?? null,
|
||||||
|
})),
|
||||||
|
accessPoints: detail.accessPoints.map((accessPoint) => ({
|
||||||
|
id: accessPoint.AccessPointId ?? '',
|
||||||
|
arn: accessPoint.AccessPointArn ?? null,
|
||||||
|
name: accessPoint.Name || tag(accessPoint.Tags, 'Name') || null,
|
||||||
|
path: accessPoint.RootDirectory?.Path ?? '/',
|
||||||
|
uid: accessPoint.PosixUser?.Uid ?? null,
|
||||||
|
gid: accessPoint.PosixUser?.Gid ?? null,
|
||||||
|
permissions: accessPoint.RootDirectory?.CreationInfo?.Permissions ?? null,
|
||||||
|
state: accessPoint.LifeCycleState ?? null,
|
||||||
|
})),
|
||||||
|
functions: detail.functions.map((fn) => {
|
||||||
|
const config = fn.FileSystemConfigs?.[0]
|
||||||
|
return {
|
||||||
|
name: fn.FunctionName ?? '',
|
||||||
|
accessPointId: config?.Arn?.split('/').pop() ?? null,
|
||||||
|
mountPath: config?.LocalMountPath ?? null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
} : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tag(tags: Array<{Key?: string; Value?: string}> | undefined, key: string): string | undefined {
|
||||||
|
return tags?.find((candidate) => candidate.Key === key)?.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
function posixId(value: unknown, field: string): number {
|
||||||
|
const id = typeof value === 'number' ? value : Number(text(value))
|
||||||
|
if (!Number.isInteger(id) || id < 0 || id > MAX_POSIX_ID) throw new ValidationError(`${field} must be a whole number from 0 to ${MAX_POSIX_ID}`)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneOf<T extends string>(value: string, allowed: readonly T[], field: string): T {
|
||||||
|
if (!(allowed as readonly string[]).includes(value)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
|
||||||
|
return value as T
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : typeof value === 'number' ? String(value) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import {describe, expect, test} from 'bun:test'
|
||||||
|
import {
|
||||||
|
type AthenaClient,
|
||||||
|
CreateWorkGroupCommand,
|
||||||
|
DeleteWorkGroupCommand,
|
||||||
|
GetQueryExecutionCommand,
|
||||||
|
GetQueryResultsCommand,
|
||||||
|
GetWorkGroupCommand,
|
||||||
|
ListDatabasesCommand,
|
||||||
|
ListQueryExecutionsCommand,
|
||||||
|
ListTableMetadataCommand,
|
||||||
|
ListWorkGroupsCommand,
|
||||||
|
StartQueryExecutionCommand,
|
||||||
|
StopQueryExecutionCommand,
|
||||||
|
} from '@aws-sdk/client-athena'
|
||||||
|
import {AwsQueryAdapter} from './AwsQueryAdapter'
|
||||||
|
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
|
||||||
|
type SendResult = Record<string, unknown>
|
||||||
|
|
||||||
|
function execution(id: string, overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
QueryExecutionId: id,
|
||||||
|
Query: 'SELECT name, amount FROM orders',
|
||||||
|
StatementType: 'DML',
|
||||||
|
WorkGroup: 'analysts',
|
||||||
|
QueryExecutionContext: {Database: 'shop', Catalog: 'AwsDataCatalog'},
|
||||||
|
ResultConfiguration: {OutputLocation: `s3://results/${id}.csv`},
|
||||||
|
Status: {State: 'SUCCEEDED', SubmissionDateTime: new Date('2026-10-25T21:00:00Z'), CompletionDateTime: new Date('2026-10-25T21:00:01Z')},
|
||||||
|
Statistics: {TotalExecutionTimeInMillis: 42, DataScannedInBytes: 0},
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = {Data: [{VarCharValue: 'name'}, {VarCharValue: 'amount'}]}
|
||||||
|
const columnInfo = [{Name: 'name', Label: 'name', Type: 'varchar'}, {Name: 'amount', Label: 'amount', Type: 'double'}]
|
||||||
|
const row = (name: string, amount: string) => ({Data: [{VarCharValue: name}, {VarCharValue: amount}]})
|
||||||
|
|
||||||
|
function adapterWith(handler: (command: object) => SendResult | undefined = () => undefined) {
|
||||||
|
const sent: object[] = []
|
||||||
|
const client = {
|
||||||
|
async send(command: object) {
|
||||||
|
sent.push(command)
|
||||||
|
const result = handler(command)
|
||||||
|
if (result) return result
|
||||||
|
if (command instanceof GetQueryExecutionCommand) return {QueryExecution: execution(command.input.QueryExecutionId ?? '')}
|
||||||
|
if (command instanceof GetWorkGroupCommand) {
|
||||||
|
return {WorkGroup: {Name: command.input.WorkGroup, State: 'ENABLED', Description: 'Team', Configuration: {ResultConfiguration: {OutputLocation: 's3://results/'}, EnforceWorkGroupConfiguration: true, EngineVersion: {EffectiveEngineVersion: 'Athena engine version 3'}}}}
|
||||||
|
}
|
||||||
|
if (command instanceof StartQueryExecutionCommand) return {QueryExecutionId: 'q-new'}
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {sent, adapter: new AwsQueryAdapter(client as unknown as AthenaClient)}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AwsQueryAdapter', () => {
|
||||||
|
test('lists workgroups across pages and describes one with its result location', async () => {
|
||||||
|
const {adapter} = adapterWith((command) => command instanceof ListWorkGroupsCommand
|
||||||
|
? command.input.NextToken ? {WorkGroups: [{Name: 'analysts', State: 'ENABLED', Description: 'Team'}]} : {WorkGroups: [{Name: 'primary', State: 'ENABLED'}], NextToken: 'n'}
|
||||||
|
: undefined)
|
||||||
|
|
||||||
|
expect((await adapter.list()).map((wg) => [wg.name, wg.status])).toEqual([['primary', 'ENABLED'], ['analysts', 'ENABLED']])
|
||||||
|
expect((await adapter.list({search: 'team'})).map((wg) => wg.name)).toEqual(['analysts'])
|
||||||
|
expect((await adapter.get('analysts'))?.metadata).toEqual({description: 'Team', outputLocation: 's3://results/', enforceConfiguration: true, engineVersion: 'Athena engine version 3'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a workgroup with a result folder, and keeps the primary workgroup', async () => {
|
||||||
|
const {adapter, sent} = adapterWith()
|
||||||
|
await adapter.create({values: {name: 'analysts', description: 'Team', outputLocation: 's3://results/athena', enforce: 'true'}})
|
||||||
|
expect((sent.find((command) => command instanceof CreateWorkGroupCommand) as CreateWorkGroupCommand).input).toEqual({
|
||||||
|
Name: 'analysts', Description: 'Team', Configuration: {ResultConfiguration: {OutputLocation: 's3://results/athena/'}, EnforceWorkGroupConfiguration: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(adapter.create({values: {name: 'primary'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.create({values: {name: 'bad name'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.delete('primary')).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await adapter.delete('analysts')
|
||||||
|
expect((sent.find((command) => command instanceof DeleteWorkGroupCommand) as DeleteWorkGroupCommand).input).toEqual({WorkGroup: 'analysts', RecursiveDeleteOption: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('starts a query in the workgroup against a database', async () => {
|
||||||
|
const {adapter, sent} = adapterWith()
|
||||||
|
const started = await adapter.startQuery('analysts', {sql: ' SELECT 1 ', database: 'shop'})
|
||||||
|
|
||||||
|
expect((sent.find((command) => command instanceof StartQueryExecutionCommand) as StartQueryExecutionCommand).input).toEqual({
|
||||||
|
QueryString: 'SELECT 1', WorkGroup: 'analysts', QueryExecutionContext: {Catalog: 'AwsDataCatalog', Database: 'shop'},
|
||||||
|
})
|
||||||
|
expect(started).toMatchObject({id: 'q-new', state: 'SUCCEEDED', runtimeMs: 42, outputLocation: 's3://results/q-new.csv'})
|
||||||
|
await expect(adapter.startQuery('analysts', {sql: ' '})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns a succeeded query with its columns and rows, without the header row', async () => {
|
||||||
|
const {adapter} = adapterWith((command) => command instanceof GetQueryResultsCommand
|
||||||
|
? {ResultSet: {ResultSetMetadata: {ColumnInfo: columnInfo}, Rows: [header, row('alpha', '10'), row('beta', '25')]}}
|
||||||
|
: undefined)
|
||||||
|
|
||||||
|
const result = await adapter.getQueryResult('analysts', 'q1')
|
||||||
|
expect(result.columns).toEqual([{name: 'name', type: 'varchar'}, {name: 'amount', type: 'double'}])
|
||||||
|
expect(result.rows).toEqual([['alpha', '10'], ['beta', '25']])
|
||||||
|
expect(result.truncated).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('stops reading after the row limit and says the result is truncated', async () => {
|
||||||
|
const page = Array.from({length: 1000}, (_, i) => row(`n${i}`, String(i)))
|
||||||
|
const {adapter, sent} = adapterWith((command) => command instanceof GetQueryResultsCommand
|
||||||
|
? command.input.NextToken ? {ResultSet: {Rows: page}, NextToken: 'more'} : {ResultSet: {ResultSetMetadata: {ColumnInfo: columnInfo}, Rows: [header, ...page.slice(0, 999)]}, NextToken: 'p2'}
|
||||||
|
: undefined)
|
||||||
|
|
||||||
|
const result = await adapter.getQueryResult('analysts', 'q1')
|
||||||
|
expect(result.rows).toHaveLength(1000)
|
||||||
|
expect(result.truncated).toBe(true)
|
||||||
|
expect(sent.filter((command) => command instanceof GetQueryResultsCommand)).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not read results of a query that has not succeeded', async () => {
|
||||||
|
const {adapter, sent} = adapterWith((command) => command instanceof GetQueryExecutionCommand
|
||||||
|
? {QueryExecution: execution('q1', {Status: {State: 'FAILED', StateChangeReason: 'Table orders not found'}})}
|
||||||
|
: undefined)
|
||||||
|
|
||||||
|
const result = await adapter.getQueryResult('analysts', 'q1')
|
||||||
|
expect(result.execution).toMatchObject({state: 'FAILED', stateReason: 'Table orders not found'})
|
||||||
|
expect(result.rows).toEqual([])
|
||||||
|
expect(sent.some((command) => command instanceof GetQueryResultsCommand)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses a query from another workgroup', async () => {
|
||||||
|
const {adapter, sent} = adapterWith()
|
||||||
|
await expect(adapter.getQueryResult('primary', 'q1')).rejects.toBeInstanceOf(NotFoundError)
|
||||||
|
await expect(adapter.stopQuery('primary', 'q1')).rejects.toBeInstanceOf(NotFoundError)
|
||||||
|
expect(sent.some((command) => command instanceof StopQueryExecutionCommand)).toBe(false)
|
||||||
|
|
||||||
|
await adapter.stopQuery('analysts', 'q1')
|
||||||
|
expect(sent.some((command) => command instanceof StopQueryExecutionCommand)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lists the workgroup queries newest first', async () => {
|
||||||
|
const {adapter} = adapterWith((command) => {
|
||||||
|
if (command instanceof ListQueryExecutionsCommand) return {QueryExecutionIds: ['old', 'new', 'other']}
|
||||||
|
if (command instanceof GetQueryExecutionCommand) {
|
||||||
|
const id = command.input.QueryExecutionId
|
||||||
|
return {QueryExecution: execution(id ?? '', {
|
||||||
|
WorkGroup: id === 'other' ? 'primary' : 'analysts',
|
||||||
|
Status: {State: 'SUCCEEDED', SubmissionDateTime: new Date(id === 'new' ? '2026-10-25T22:00:00Z' : '2026-10-25T21:00:00Z')},
|
||||||
|
})}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
expect((await adapter.listQueries('analysts')).map((query) => query.id)).toEqual(['new', 'old'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lists databases and the tables of the chosen one', async () => {
|
||||||
|
const {adapter, sent} = adapterWith((command) => {
|
||||||
|
if (command instanceof ListDatabasesCommand) return {DatabaseList: [{Name: 'shop'}, {Name: 'logs'}]}
|
||||||
|
if (command instanceof ListTableMetadataCommand) {
|
||||||
|
return {TableMetadataList: [{Name: 'orders', Columns: [{Name: 'id', Type: 'int'}], PartitionKeys: [{Name: 'day', Type: 'string'}]}]}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await adapter.describeQueryCatalog('shop')).toEqual({databases: ['logs', 'shop'], database: 'shop', tables: [{name: 'orders', columns: [{name: 'id', type: 'int'}, {name: 'day', type: 'string'}]}]})
|
||||||
|
expect((await adapter.describeQueryCatalog('missing')).database).toBe('logs')
|
||||||
|
expect((sent.filter((command) => command instanceof ListTableMetadataCommand) as ListTableMetadataCommand[]).map((command) => command.input.DatabaseName)).toEqual(['shop', 'logs'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import {
|
||||||
|
type AthenaClient,
|
||||||
|
CreateWorkGroupCommand,
|
||||||
|
DeleteWorkGroupCommand,
|
||||||
|
GetQueryExecutionCommand,
|
||||||
|
GetQueryResultsCommand,
|
||||||
|
type GetQueryResultsCommandOutput,
|
||||||
|
GetWorkGroupCommand,
|
||||||
|
ListDatabasesCommand,
|
||||||
|
ListQueryExecutionsCommand,
|
||||||
|
ListTableMetadataCommand,
|
||||||
|
type ListTableMetadataCommandOutput,
|
||||||
|
ListWorkGroupsCommand,
|
||||||
|
type QueryExecution,
|
||||||
|
StartQueryExecutionCommand,
|
||||||
|
StopQueryExecutionCommand,
|
||||||
|
type TableMetadata,
|
||||||
|
type WorkGroup,
|
||||||
|
type WorkGroupSummary,
|
||||||
|
} from '@aws-sdk/client-athena'
|
||||||
|
import {NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
import {
|
||||||
|
awsQuerySchema,
|
||||||
|
PRIMARY_WORKGROUP,
|
||||||
|
QUERY_HISTORY_LIMIT,
|
||||||
|
QUERY_RESULT_ROW_LIMIT,
|
||||||
|
WORKGROUP_NAME_PATTERN,
|
||||||
|
} from '../cloud-spi/querySchema'
|
||||||
|
import type {
|
||||||
|
CloudResource,
|
||||||
|
CloudServiceAdapter,
|
||||||
|
CreateResourceInput,
|
||||||
|
QueryCatalog,
|
||||||
|
QueryExecutionSummary,
|
||||||
|
QueryInput,
|
||||||
|
QueryResult,
|
||||||
|
ResourceQuery,
|
||||||
|
ServiceSchema,
|
||||||
|
} from '../cloud-spi/types'
|
||||||
|
|
||||||
|
/** Athena's own catalog name for the Glue Data Catalog. */
|
||||||
|
const DATA_CATALOG = 'AwsDataCatalog'
|
||||||
|
/** Athena's cap on a query string. */
|
||||||
|
const MAX_QUERY_LENGTH = 262_144
|
||||||
|
const S3_URI = /^s3:\/\/[a-z0-9.-]{3,63}(\/.*)?$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talks to Amazon Athena, verified against Floci core (community, `latest`).
|
||||||
|
*
|
||||||
|
* Notes from probing the runtime:
|
||||||
|
* - Queries really run: TheCloud executes the SQL with DuckDB over the files a
|
||||||
|
* Glue table points at, and writes the result to S3 as CSV, as Athena does.
|
||||||
|
* - Statements that return no rows, such as SHOW TABLES, fail in the runtime,
|
||||||
|
* so the editor lists databases and tables through the catalog APIs instead.
|
||||||
|
* - Data scanned is always reported as 0 bytes, so the editor shows only the
|
||||||
|
* run time.
|
||||||
|
*/
|
||||||
|
export class AwsQueryAdapter implements CloudServiceAdapter {
|
||||||
|
readonly cloud = 'aws' as const
|
||||||
|
readonly service = 'query' as const
|
||||||
|
|
||||||
|
constructor(private readonly athena: AthenaClient) {}
|
||||||
|
|
||||||
|
schema(): ServiceSchema {
|
||||||
|
return awsQuerySchema()
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||||
|
const found: WorkGroupSummary[] = []
|
||||||
|
let next: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.athena.send(new ListWorkGroupsCommand({MaxResults: 50, ...(next ? {NextToken: next} : {})}))
|
||||||
|
found.push(...(res.WorkGroups ?? []))
|
||||||
|
next = res.NextToken
|
||||||
|
} while (next)
|
||||||
|
|
||||||
|
const search = query.search?.trim().toLowerCase()
|
||||||
|
return found.map(toResource).filter((workGroup) => !search
|
||||||
|
|| workGroup.name.toLowerCase().includes(search)
|
||||||
|
|| String(workGroup.metadata.description ?? '').toLowerCase().includes(search))
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<CloudResource | null> {
|
||||||
|
try {
|
||||||
|
const workGroup = (await this.athena.send(new GetWorkGroupCommand({WorkGroup: id}))).WorkGroup
|
||||||
|
return workGroup ? toResource(workGroup) : null
|
||||||
|
} catch (error) {
|
||||||
|
if (errorName(error) === 'InvalidRequestException') return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||||
|
const values = input.values
|
||||||
|
const name = text(values.name)
|
||||||
|
if (!new RegExp(WORKGROUP_NAME_PATTERN).test(name)) throw new ValidationError('name must be up to 128 letters, numbers, dots, dashes or underscores')
|
||||||
|
if (name === PRIMARY_WORKGROUP) throw new ValidationError('Every account already has the primary workgroup')
|
||||||
|
const outputLocation = text(values.outputLocation)
|
||||||
|
if (outputLocation && !S3_URI.test(outputLocation)) throw new ValidationError('outputLocation must be an S3 URI such as s3://my-bucket/athena-results/')
|
||||||
|
const description = text(values.description)
|
||||||
|
|
||||||
|
await this.athena.send(new CreateWorkGroupCommand({
|
||||||
|
Name: name,
|
||||||
|
...(description ? {Description: description} : {}),
|
||||||
|
Configuration: {
|
||||||
|
...(outputLocation ? {ResultConfiguration: {OutputLocation: outputLocation.endsWith('/') ? outputLocation : `${outputLocation}/`}} : {}),
|
||||||
|
EnforceWorkGroupConfiguration: values.enforce === 'true',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
const created = await this.get(name)
|
||||||
|
if (!created) throw new RuntimeError(`Athena did not return the new workgroup ${name}`)
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
if (id === PRIMARY_WORKGROUP) throw new ValidationError('The primary workgroup cannot be deleted')
|
||||||
|
await this.athena.send(new DeleteWorkGroupCommand({WorkGroup: id, RecursiveDeleteOption: true}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async startQuery(workGroup: string, input: QueryInput): Promise<QueryExecutionSummary> {
|
||||||
|
const sql = typeof input.sql === 'string' ? input.sql.trim() : ''
|
||||||
|
if (!sql) throw new ValidationError('Enter a query to run')
|
||||||
|
if (sql.length > MAX_QUERY_LENGTH) throw new ValidationError(`A query can be at most ${MAX_QUERY_LENGTH} characters`)
|
||||||
|
const database = text(input.database)
|
||||||
|
|
||||||
|
const started = await this.athena.send(new StartQueryExecutionCommand({
|
||||||
|
QueryString: sql,
|
||||||
|
WorkGroup: workGroup,
|
||||||
|
...(database ? {QueryExecutionContext: {Catalog: DATA_CATALOG, Database: database}} : {}),
|
||||||
|
}))
|
||||||
|
if (!started.QueryExecutionId) throw new RuntimeError('Athena did not return a query id')
|
||||||
|
return (await this.execution(workGroup, started.QueryExecutionId))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getQueryResult(workGroup: string, queryId: string): Promise<QueryResult> {
|
||||||
|
const execution = await this.execution(workGroup, queryId)
|
||||||
|
if (execution.state !== 'SUCCEEDED' || !execution.outputLocation) return {execution, columns: [], rows: [], truncated: false}
|
||||||
|
|
||||||
|
const pages: GetQueryResultsCommandOutput[] = []
|
||||||
|
let next: string | undefined
|
||||||
|
let rowCount = 0
|
||||||
|
do {
|
||||||
|
const page = await this.athena.send(new GetQueryResultsCommand({QueryExecutionId: queryId, MaxResults: 1000, ...(next ? {NextToken: next} : {})}))
|
||||||
|
pages.push(page)
|
||||||
|
rowCount += page.ResultSet?.Rows?.length ?? 0
|
||||||
|
next = page.NextToken
|
||||||
|
} while (next && rowCount <= QUERY_RESULT_ROW_LIMIT)
|
||||||
|
|
||||||
|
const columnInfo = pages[0]?.ResultSet?.ResultSetMetadata?.ColumnInfo ?? []
|
||||||
|
const raw = pages.flatMap((page) => page.ResultSet?.Rows ?? []).map((row) => (row.Data ?? []).map((cell) => cell.VarCharValue ?? null))
|
||||||
|
const header = raw[0] ?? []
|
||||||
|
const columns = columnInfo.length > 0
|
||||||
|
? columnInfo.map((column) => ({name: column.Label ?? column.Name ?? '', type: column.Type ?? null}))
|
||||||
|
: header.map((name) => ({name: name ?? '', type: null}))
|
||||||
|
// Athena repeats the column names as the first row of a SELECT's results.
|
||||||
|
const body = raw.length > 0 && header.length === columns.length && header.every((name, index) => name === columns[index].name) ? raw.slice(1) : raw
|
||||||
|
return {execution, columns, rows: body.slice(0, QUERY_RESULT_ROW_LIMIT), truncated: body.length > QUERY_RESULT_ROW_LIMIT || Boolean(next)}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopQuery(workGroup: string, queryId: string): Promise<void> {
|
||||||
|
await this.execution(workGroup, queryId)
|
||||||
|
await this.athena.send(new StopQueryExecutionCommand({QueryExecutionId: queryId}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async listQueries(workGroup: string): Promise<QueryExecutionSummary[]> {
|
||||||
|
const ids = (await this.athena.send(new ListQueryExecutionsCommand({WorkGroup: workGroup, MaxResults: QUERY_HISTORY_LIMIT}))).QueryExecutionIds ?? []
|
||||||
|
const executions = await Promise.all(ids.slice(0, QUERY_HISTORY_LIMIT).map(async (id) => {
|
||||||
|
const res = await this.athena.send(new GetQueryExecutionCommand({QueryExecutionId: id}))
|
||||||
|
return res.QueryExecution ? toExecution(res.QueryExecution) : null
|
||||||
|
}))
|
||||||
|
return executions
|
||||||
|
.filter((execution): execution is QueryExecutionSummary => execution !== null && (execution.workGroup ?? PRIMARY_WORKGROUP) === workGroup)
|
||||||
|
.sort((a, b) => (b.submittedAt ?? '').localeCompare(a.submittedAt ?? ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
async describeQueryCatalog(database?: string): Promise<QueryCatalog> {
|
||||||
|
const databases: string[] = []
|
||||||
|
let next: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.athena.send(new ListDatabasesCommand({CatalogName: DATA_CATALOG, MaxResults: 50, ...(next ? {NextToken: next} : {})}))
|
||||||
|
databases.push(...(res.DatabaseList ?? []).map((entry) => entry.Name ?? '').filter(Boolean))
|
||||||
|
next = res.NextToken
|
||||||
|
} while (next)
|
||||||
|
databases.sort()
|
||||||
|
|
||||||
|
const chosen = database && databases.includes(database) ? database : databases[0] ?? null
|
||||||
|
if (!chosen) return {databases, database: null, tables: []}
|
||||||
|
|
||||||
|
const tables: TableMetadata[] = []
|
||||||
|
let tableToken: string | undefined
|
||||||
|
do {
|
||||||
|
const res: ListTableMetadataCommandOutput = await this.athena.send(new ListTableMetadataCommand({CatalogName: DATA_CATALOG, DatabaseName: chosen, MaxResults: 50, ...(tableToken ? {NextToken: tableToken} : {})}))
|
||||||
|
tables.push(...(res.TableMetadataList ?? []))
|
||||||
|
tableToken = res.NextToken
|
||||||
|
} while (tableToken)
|
||||||
|
return {
|
||||||
|
databases,
|
||||||
|
database: chosen,
|
||||||
|
tables: tables.map((table) => ({
|
||||||
|
name: table.Name ?? '',
|
||||||
|
columns: [...(table.Columns ?? []), ...(table.PartitionKeys ?? [])].map((column) => ({name: column.Name ?? '', type: column.Type ?? ''})),
|
||||||
|
})).sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A query, refused unless it ran in this workgroup, so one workgroup's page never shows another's. */
|
||||||
|
private async execution(workGroup: string, queryId: string): Promise<QueryExecutionSummary> {
|
||||||
|
let execution: QueryExecution | undefined
|
||||||
|
try {
|
||||||
|
execution = (await this.athena.send(new GetQueryExecutionCommand({QueryExecutionId: queryId}))).QueryExecution
|
||||||
|
} catch (error) {
|
||||||
|
if (errorName(error) === 'InvalidRequestException') throw new NotFoundError(`Query ${queryId} not found`)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!execution || (execution.WorkGroup ?? PRIMARY_WORKGROUP) !== workGroup) throw new NotFoundError(`Query ${queryId} is not in the ${workGroup} workgroup`)
|
||||||
|
return toExecution(execution)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResource(workGroup: WorkGroup | WorkGroupSummary): CloudResource {
|
||||||
|
const name = workGroup.Name ?? ''
|
||||||
|
const detail = 'Configuration' in workGroup ? workGroup : undefined
|
||||||
|
const configuration = detail?.Configuration
|
||||||
|
return {
|
||||||
|
id: name,
|
||||||
|
name,
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'query',
|
||||||
|
type: 'athenaWorkGroup',
|
||||||
|
region: null,
|
||||||
|
createdAt: workGroup.CreationTime ? workGroup.CreationTime.toISOString() : null,
|
||||||
|
status: workGroup.State ?? null,
|
||||||
|
metadata: {
|
||||||
|
description: workGroup.Description || null,
|
||||||
|
...(detail ? {
|
||||||
|
outputLocation: configuration?.ResultConfiguration?.OutputLocation ?? null,
|
||||||
|
enforceConfiguration: configuration?.EnforceWorkGroupConfiguration === true,
|
||||||
|
engineVersion: configuration?.EngineVersion?.EffectiveEngineVersion ?? configuration?.EngineVersion?.SelectedEngineVersion ?? null,
|
||||||
|
} : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toExecution(execution: QueryExecution): QueryExecutionSummary {
|
||||||
|
const status = execution.Status
|
||||||
|
return {
|
||||||
|
id: execution.QueryExecutionId ?? '',
|
||||||
|
sql: execution.Query ?? '',
|
||||||
|
database: execution.QueryExecutionContext?.Database ?? null,
|
||||||
|
workGroup: execution.WorkGroup ?? null,
|
||||||
|
state: status?.State ?? 'QUEUED',
|
||||||
|
stateReason: status?.StateChangeReason ?? status?.AthenaError?.ErrorMessage ?? null,
|
||||||
|
statementType: execution.StatementType ?? null,
|
||||||
|
submittedAt: status?.SubmissionDateTime ? status.SubmissionDateTime.toISOString() : null,
|
||||||
|
completedAt: status?.CompletionDateTime ? status.CompletionDateTime.toISOString() : null,
|
||||||
|
runtimeMs: execution.Statistics?.TotalExecutionTimeInMillis ?? execution.Statistics?.EngineExecutionTimeInMillis ?? null,
|
||||||
|
dataScannedBytes: execution.Statistics?.DataScannedInBytes ?? null,
|
||||||
|
outputLocation: execution.ResultConfiguration?.OutputLocation ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -34,7 +34,12 @@ import { LightsailClient } from "@aws-sdk/client-lightsail";
|
|||||||
import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider";
|
import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider";
|
||||||
import { Route53Client } from "@aws-sdk/client-route-53";
|
import { Route53Client } from "@aws-sdk/client-route-53";
|
||||||
import { CloudFrontClient } from "@aws-sdk/client-cloudfront";
|
import { CloudFrontClient } from "@aws-sdk/client-cloudfront";
|
||||||
|
import { AutoScalingClient } from "@aws-sdk/client-auto-scaling";
|
||||||
import { ACMClient } from "@aws-sdk/client-acm";
|
import { ACMClient } from "@aws-sdk/client-acm";
|
||||||
|
import { PipesClient } from "@aws-sdk/client-pipes";
|
||||||
|
import { EFSClient } from "@aws-sdk/client-efs";
|
||||||
|
import { GlueClient } from "@aws-sdk/client-glue";
|
||||||
|
import { AthenaClient } from "@aws-sdk/client-athena";
|
||||||
|
|
||||||
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";
|
||||||
@@ -100,7 +105,12 @@ export type AwsClients = {
|
|||||||
cognito: CognitoIdentityProviderClient;
|
cognito: CognitoIdentityProviderClient;
|
||||||
route53: Route53Client;
|
route53: Route53Client;
|
||||||
cloudfront: CloudFrontClient;
|
cloudfront: CloudFrontClient;
|
||||||
|
autoscaling: AutoScalingClient;
|
||||||
acm: ACMClient;
|
acm: ACMClient;
|
||||||
|
pipes: PipesClient;
|
||||||
|
efs: EFSClient;
|
||||||
|
glue: GlueClient;
|
||||||
|
athena: AthenaClient;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AwsClientName = keyof AwsClients;
|
export type AwsClientName = keyof AwsClients;
|
||||||
@@ -150,7 +160,12 @@ function buildClients(accountId: string): AwsClients {
|
|||||||
cognito: new CognitoIdentityProviderClient(base),
|
cognito: new CognitoIdentityProviderClient(base),
|
||||||
route53: new Route53Client(base),
|
route53: new Route53Client(base),
|
||||||
cloudfront: new CloudFrontClient(base),
|
cloudfront: new CloudFrontClient(base),
|
||||||
|
autoscaling: new AutoScalingClient(base),
|
||||||
acm: new ACMClient(base),
|
acm: new ACMClient(base),
|
||||||
|
pipes: new PipesClient(base),
|
||||||
|
efs: new EFSClient(base),
|
||||||
|
glue: new GlueClient(base),
|
||||||
|
athena: new AthenaClient(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,68 @@
|
|||||||
|
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Glue's rule for database and table names. Glue lowercases them, and Athena
|
||||||
|
* needs them to start with a letter or underscore to query them unquoted.
|
||||||
|
*/
|
||||||
|
export const CATALOG_NAME_PATTERN = '^[a-z_][a-z0-9_]{0,254}$'
|
||||||
|
/** The data formats TheCloud's Athena engine reads from a table's S3 location. */
|
||||||
|
export const CATALOG_TABLE_FORMATS = ['csv', 'json', 'parquet'] as const
|
||||||
|
/** Hive column types Athena understands and the console offers. */
|
||||||
|
export const CATALOG_COLUMN_TYPES = ['string', 'int', 'bigint', 'double', 'float', 'boolean', 'date', 'timestamp', 'decimal(10,2)'] as const
|
||||||
|
|
||||||
|
const databaseColumns: TableColumnSchema[] = [
|
||||||
|
{name: 'name', label: 'Name'},
|
||||||
|
{name: 'tables', label: 'Tables', path: 'metadata.tableCount', emptyText: '—'},
|
||||||
|
{name: 'description', label: 'Description', path: 'metadata.description', emptyText: '—'},
|
||||||
|
{name: 'location', label: 'Location', path: 'metadata.locationUri', emptyText: '—'},
|
||||||
|
{name: 'createdAt', label: 'Created', format: 'datetime'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const databaseFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||||
|
|
||||||
|
export function awsDataCatalogSchema(): ServiceSchema {
|
||||||
|
return {
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'datacatalog',
|
||||||
|
displayName: 'Glue Data Catalog databases',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
description: 'Lowercase letters, numbers and underscores, starting with a letter or underscore, so Athena can query it.',
|
||||||
|
validation: {pattern: CATALOG_NAME_PATTERN, message: 'Use lowercase letters, numbers and underscores, starting with a letter or underscore'},
|
||||||
|
},
|
||||||
|
{name: 'description', label: 'Description', type: 'text', required: false, validation: {maxLength: 2048}},
|
||||||
|
{
|
||||||
|
name: 'locationUri',
|
||||||
|
label: 'Location',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
span: true,
|
||||||
|
description: 'Optional S3 location for the database, such as s3://my-bucket/warehouse/.',
|
||||||
|
validation: {pattern: '^s3://[a-z0-9.-]{3,63}(/.*)?$', message: 'Use an S3 URI such as s3://my-bucket/warehouse/'},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: ['list', 'create', 'delete', 'inspect'],
|
||||||
|
capabilities: {
|
||||||
|
resourceActions: [
|
||||||
|
{name: 'list', label: 'List databases', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'create', label: 'Create database', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'delete', label: 'Delete database', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'inspect', label: 'Inspect database', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
dataCatalogActions: [
|
||||||
|
{name: 'createTable', label: 'Create table', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'deleteTable', label: 'Delete table', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
filters: databaseFilters,
|
||||||
|
columns: databaseColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dataCatalogSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||||
|
return cloud === 'aws' ? awsDataCatalogSchema() : null
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||||
|
|
||||||
|
/** The Name tag EFS shows for a file system; AWS caps tag values at 256 characters. */
|
||||||
|
export const FILE_SYSTEM_NAME_PATTERN = '^[^\\x00-\\x1f]{1,256}$'
|
||||||
|
export const FILE_SYSTEM_PERFORMANCE_MODES = ['generalPurpose', 'maxIO'] as const
|
||||||
|
export const FILE_SYSTEM_THROUGHPUT_MODES = ['bursting', 'elastic'] as const
|
||||||
|
/** An access point's root: an absolute path of up to 100 characters. */
|
||||||
|
export const ACCESS_POINT_PATH_PATTERN = '^/[^\\x00]{0,99}$'
|
||||||
|
/** Lambda mounts file systems under /mnt only. */
|
||||||
|
export const LAMBDA_MOUNT_PATH_PATTERN = '^/mnt/[a-zA-Z0-9-_.]{1,160}$'
|
||||||
|
|
||||||
|
const fileSystemColumns: TableColumnSchema[] = [
|
||||||
|
{name: 'name', label: 'Name'},
|
||||||
|
{name: 'id', label: 'File system ID', path: 'metadata.fileSystemId'},
|
||||||
|
{name: 'status', label: 'State', format: 'badge'},
|
||||||
|
{name: 'mountTargets', label: 'Mount targets', path: 'metadata.numberOfMountTargets'},
|
||||||
|
{name: 'performanceMode', label: 'Performance', path: 'metadata.performanceMode'},
|
||||||
|
{name: 'encrypted', label: 'Encrypted', path: 'metadata.encrypted', format: 'boolean'},
|
||||||
|
{name: 'createdAt', label: 'Created', format: 'datetime'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const fileSystemFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||||
|
|
||||||
|
export function awsFileSystemsSchema(): ServiceSchema {
|
||||||
|
return {
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'filesystems',
|
||||||
|
displayName: 'EFS file systems',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
description: 'Stored as the Name tag, as the AWS console does.',
|
||||||
|
validation: {pattern: FILE_SYSTEM_NAME_PATTERN, message: 'Use up to 256 characters'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mountTargets',
|
||||||
|
label: 'Mount targets',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'default',
|
||||||
|
description: 'Lambda functions and ECS tasks reach a file system through a mount target in their subnet.',
|
||||||
|
options: [
|
||||||
|
{label: 'One in each default subnet', value: 'default'},
|
||||||
|
{label: 'None for now', value: 'none'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'performanceMode',
|
||||||
|
label: 'Performance mode',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'generalPurpose',
|
||||||
|
options: [
|
||||||
|
{label: 'General Purpose', value: 'generalPurpose'},
|
||||||
|
{label: 'Max I/O', value: 'maxIO'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'throughputMode',
|
||||||
|
label: 'Throughput mode',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'bursting',
|
||||||
|
options: [
|
||||||
|
{label: 'Bursting', value: 'bursting'},
|
||||||
|
{label: 'Elastic', value: 'elastic'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'encrypted',
|
||||||
|
label: 'Encryption at rest',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'true',
|
||||||
|
options: [
|
||||||
|
{label: 'Encrypted', value: 'true'},
|
||||||
|
{label: 'Not encrypted', value: 'false'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: ['list', 'create', 'delete', 'inspect'],
|
||||||
|
capabilities: {
|
||||||
|
resourceActions: [
|
||||||
|
{name: 'list', label: 'List file systems', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'create', label: 'Create file system', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'delete', label: 'Delete file system', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'inspect', label: 'Inspect file system', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
fileSystemActions: [
|
||||||
|
{name: 'createMountTarget', label: 'Add mount target', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'deleteMountTarget', label: 'Remove mount target', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'createAccessPoint', label: 'Create access point', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'deleteAccessPoint', label: 'Delete access point', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'attachFunction', label: 'Attach to a Lambda function', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'detachFunction', label: 'Detach from a Lambda function', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
filters: fileSystemFilters,
|
||||||
|
columns: fileSystemColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileSystemsSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||||
|
return cloud === 'aws' ? awsFileSystemsSchema() : null
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||||
|
|
||||||
|
/** CreateWorkGroup's name rule. */
|
||||||
|
export const WORKGROUP_NAME_PATTERN = '^[a-zA-Z0-9._-]{1,128}$'
|
||||||
|
/** The workgroup every account has, which Athena does not let you delete. */
|
||||||
|
export const PRIMARY_WORKGROUP = 'primary'
|
||||||
|
/** Rows the editor shows for one query; the full result stays in the CSV file in S3. */
|
||||||
|
export const QUERY_RESULT_ROW_LIMIT = 1000
|
||||||
|
/** Recent queries the history shows. */
|
||||||
|
export const QUERY_HISTORY_LIMIT = 25
|
||||||
|
|
||||||
|
const workGroupColumns: TableColumnSchema[] = [
|
||||||
|
{name: 'name', label: 'Name'},
|
||||||
|
{name: 'status', label: 'State', format: 'badge'},
|
||||||
|
{name: 'description', label: 'Description', path: 'metadata.description', emptyText: '—'},
|
||||||
|
{name: 'createdAt', label: 'Created', format: 'datetime', emptyText: '—'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const workGroupFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||||
|
|
||||||
|
export function awsQuerySchema(): ServiceSchema {
|
||||||
|
return {
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'query',
|
||||||
|
displayName: 'Athena workgroups',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
validation: {pattern: WORKGROUP_NAME_PATTERN, message: 'Use up to 128 letters, numbers, dots, dashes and underscores'},
|
||||||
|
},
|
||||||
|
{name: 'description', label: 'Description', type: 'text', required: false, validation: {maxLength: 1024}},
|
||||||
|
{
|
||||||
|
name: 'outputLocation',
|
||||||
|
label: 'Query result location',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
span: true,
|
||||||
|
description: 'Where each query writes its results as a CSV file, such as s3://my-bucket/athena-results/. Leave empty to use TheCloud\'s default results bucket.',
|
||||||
|
validation: {pattern: '^s3://[a-z0-9.-]{3,63}(/.*)?$', message: 'Use an S3 URI such as s3://my-bucket/athena-results/'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'enforce',
|
||||||
|
label: 'Override client-side settings',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'false',
|
||||||
|
description: 'When on, queries always use this workgroup\'s result location, even if a client asks for another.',
|
||||||
|
options: [
|
||||||
|
{label: 'Off', value: 'false'},
|
||||||
|
{label: 'On', value: 'true'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: ['list', 'create', 'delete', 'inspect'],
|
||||||
|
capabilities: {
|
||||||
|
resourceActions: [
|
||||||
|
{name: 'list', label: 'List workgroups', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'create', label: 'Create workgroup', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'delete', label: 'Delete workgroup', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'inspect', label: 'Inspect workgroup', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
queryActions: [
|
||||||
|
{name: 'run', label: 'Run query', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'results', label: 'Get query results', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'stop', label: 'Cancel query', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'history', label: 'Recent queries', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'catalog', label: 'Browse databases and tables', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
filters: workGroupFilters,
|
||||||
|
columns: workGroupColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function querySchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||||
|
return cloud === 'aws' ? awsQuerySchema() : 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'},
|
||||||
@@ -125,6 +132,13 @@ export const SERVICE_CATALOG = {
|
|||||||
group: 'Storage',
|
group: 'Storage',
|
||||||
order: 20,
|
order: 20,
|
||||||
},
|
},
|
||||||
|
filesystems: {
|
||||||
|
displayName: 'File Systems',
|
||||||
|
displayNameByCloud: {aws: 'EFS'},
|
||||||
|
iconKey: 'filesystems',
|
||||||
|
group: 'Storage',
|
||||||
|
order: 30,
|
||||||
|
},
|
||||||
database: {displayName: 'Database', iconKey: 'database', group: 'Databases', order: 10},
|
database: {displayName: 'Database', iconKey: 'database', group: 'Databases', order: 10},
|
||||||
nosql: {
|
nosql: {
|
||||||
displayName: 'NoSQL',
|
displayName: 'NoSQL',
|
||||||
@@ -208,6 +222,20 @@ export const SERVICE_CATALOG = {
|
|||||||
group: 'Analytics',
|
group: 'Analytics',
|
||||||
order: 18,
|
order: 18,
|
||||||
},
|
},
|
||||||
|
datacatalog: {
|
||||||
|
displayName: 'Data Catalog',
|
||||||
|
displayNameByCloud: {aws: 'Glue Data Catalog'},
|
||||||
|
iconKey: 'datacatalog',
|
||||||
|
group: 'Analytics',
|
||||||
|
order: 20,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
displayName: 'Query',
|
||||||
|
displayNameByCloud: {aws: 'Athena'},
|
||||||
|
iconKey: 'query',
|
||||||
|
group: 'Analytics',
|
||||||
|
order: 21,
|
||||||
|
},
|
||||||
identity: {displayName: 'Identity', iconKey: 'iam', group: 'Security', order: 5},
|
identity: {displayName: 'Identity', iconKey: 'iam', group: 'Security', order: 5},
|
||||||
apigateway: {displayName: 'API Gateway', iconKey: 'apigateway', group: 'Integration', order: 10},
|
apigateway: {displayName: 'API Gateway', iconKey: 'apigateway', group: 'Integration', order: 10},
|
||||||
email: {
|
email: {
|
||||||
@@ -281,6 +309,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'},
|
||||||
|
|||||||
@@ -186,7 +186,11 @@ export type UserPoolActionName =
|
|||||||
| 'describeClient'
|
| 'describeClient'
|
||||||
| 'updateClientOAuth'
|
| 'updateClientOAuth'
|
||||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||||
|
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
|
||||||
export type CertificateActionName = 'download' | 'export' | 'revoke'
|
export type CertificateActionName = 'download' | 'export' | 'revoke'
|
||||||
|
export type QueryActionName = 'run' | 'results' | 'stop' | 'history' | 'catalog'
|
||||||
|
export type DataCatalogActionName = 'createTable' | 'deleteTable'
|
||||||
|
export type FileSystemActionName = 'createMountTarget' | 'deleteMountTarget' | 'createAccessPoint' | 'deleteAccessPoint' | 'attachFunction' | 'detachFunction'
|
||||||
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
||||||
export type HttpApiActionName =
|
export type HttpApiActionName =
|
||||||
| 'describe'
|
| 'describe'
|
||||||
@@ -246,7 +250,11 @@ export interface ServiceSchema {
|
|||||||
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
|
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
|
||||||
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
|
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
|
||||||
distributionActions?: CapabilitySchema<DistributionActionName>[]
|
distributionActions?: CapabilitySchema<DistributionActionName>[]
|
||||||
|
autoScalingActions?: CapabilitySchema<AutoScalingActionName>[]
|
||||||
certificateActions?: CapabilitySchema<CertificateActionName>[]
|
certificateActions?: CapabilitySchema<CertificateActionName>[]
|
||||||
|
fileSystemActions?: CapabilitySchema<FileSystemActionName>[]
|
||||||
|
dataCatalogActions?: CapabilitySchema<DataCatalogActionName>[]
|
||||||
|
queryActions?: CapabilitySchema<QueryActionName>[]
|
||||||
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>[]
|
||||||
@@ -537,6 +545,84 @@ export type CertificateRevocationReason =
|
|||||||
| 'CESSATION_OF_OPERATION'
|
| 'CESSATION_OF_OPERATION'
|
||||||
| 'PRIVILEGE_WITHDRAWN'
|
| 'PRIVILEGE_WITHDRAWN'
|
||||||
|
|
||||||
|
/** Any of the three may be left out to keep its current value. */
|
||||||
|
export interface AutoScalingCapacityInput {
|
||||||
|
minSize?: number
|
||||||
|
desiredCapacity?: number
|
||||||
|
maxSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessPointInput {
|
||||||
|
name?: string
|
||||||
|
/** Where the access point's root sits inside the file system; created on first use. */
|
||||||
|
path: string
|
||||||
|
uid: number
|
||||||
|
gid: number
|
||||||
|
/** Octal permissions for the root directory when EFS creates it, such as 755. */
|
||||||
|
permissions: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryInput {
|
||||||
|
sql: string
|
||||||
|
/** The Glue database unqualified table names resolve in. */
|
||||||
|
database?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryExecutionSummary {
|
||||||
|
id: string
|
||||||
|
sql: string
|
||||||
|
database: string | null
|
||||||
|
workGroup: string | null
|
||||||
|
/** QUEUED, RUNNING, SUCCEEDED, FAILED or CANCELLED. */
|
||||||
|
state: string
|
||||||
|
stateReason: string | null
|
||||||
|
statementType: string | null
|
||||||
|
submittedAt: string | null
|
||||||
|
completedAt: string | null
|
||||||
|
runtimeMs: number | null
|
||||||
|
dataScannedBytes: number | null
|
||||||
|
/** The S3 object holding the full result as CSV. */
|
||||||
|
outputLocation: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryResult {
|
||||||
|
execution: QueryExecutionSummary
|
||||||
|
columns: Array<{name: string; type: string | null}>
|
||||||
|
rows: Array<Array<string | null>>
|
||||||
|
/** More rows exist than the console returns; the full result is in the output file. */
|
||||||
|
truncated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryCatalog {
|
||||||
|
databases: string[]
|
||||||
|
database: string | null
|
||||||
|
tables: Array<{name: string; columns: Array<{name: string; type: string}>}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogColumnInput {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogTableInput {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
/** The S3 prefix holding the table's files, such as s3://bucket/orders/. */
|
||||||
|
location: string
|
||||||
|
format: 'csv' | 'json' | 'parquet'
|
||||||
|
/** CSV only: the field separator, a comma unless given. */
|
||||||
|
delimiter?: string
|
||||||
|
/** CSV only: whether the first line of each file is a header to skip. */
|
||||||
|
headerRow?: boolean
|
||||||
|
columns: CatalogColumnInput[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileSystemAttachmentInput {
|
||||||
|
accessPointId: string
|
||||||
|
functionName: string
|
||||||
|
mountPath: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface DistributionInvalidation {
|
export interface DistributionInvalidation {
|
||||||
id: string
|
id: string
|
||||||
status: string
|
status: string
|
||||||
@@ -1204,7 +1290,26 @@ export interface CloudServiceAdapter {
|
|||||||
setAlarmState?(alarmName: string, state: AlarmStateValue, reason: string): Promise<void>
|
setAlarmState?(alarmName: string, state: AlarmStateValue, reason: string): Promise<void>
|
||||||
setAlarmActionsEnabled?(alarmName: string, enabled: boolean): Promise<void>
|
setAlarmActionsEnabled?(alarmName: string, enabled: boolean): Promise<void>
|
||||||
createDistributionInvalidation?(distributionId: string, paths: string[]): Promise<DistributionInvalidation>
|
createDistributionInvalidation?(distributionId: string, paths: string[]): Promise<DistributionInvalidation>
|
||||||
|
setAutoScalingCapacity?(groupName: string, input: AutoScalingCapacityInput): Promise<CloudResource>
|
||||||
|
/** With `replace`, the group launches a new instance in its place; otherwise desired capacity drops by one. */
|
||||||
|
terminateAutoScalingInstance?(groupName: string, instanceId: string, replace: boolean): Promise<void>
|
||||||
getCertificatePem?(certificateId: string): Promise<CertificatePem>
|
getCertificatePem?(certificateId: string): Promise<CertificatePem>
|
||||||
|
startQuery?(workGroup: string, input: QueryInput): Promise<QueryExecutionSummary>
|
||||||
|
/** The query's state, and its first rows once it has succeeded. */
|
||||||
|
getQueryResult?(workGroup: string, queryId: string): Promise<QueryResult>
|
||||||
|
stopQuery?(workGroup: string, queryId: string): Promise<void>
|
||||||
|
listQueries?(workGroup: string): Promise<QueryExecutionSummary[]>
|
||||||
|
/** The databases a query can use and, for the one named, its tables. */
|
||||||
|
describeQueryCatalog?(database?: string): Promise<QueryCatalog>
|
||||||
|
createCatalogTable?(databaseName: string, input: CatalogTableInput): Promise<CloudResource>
|
||||||
|
deleteCatalogTable?(databaseName: string, tableName: string): Promise<CloudResource>
|
||||||
|
createMountTarget?(fileSystemId: string, subnetId: string): Promise<CloudResource>
|
||||||
|
deleteMountTarget?(fileSystemId: string, mountTargetId: string): Promise<CloudResource>
|
||||||
|
createAccessPoint?(fileSystemId: string, input: AccessPointInput): Promise<CloudResource>
|
||||||
|
deleteAccessPoint?(fileSystemId: string, accessPointId: string): Promise<CloudResource>
|
||||||
|
/** Mounts one of the file system's access points into a Lambda function, as UpdateFunctionConfiguration does. */
|
||||||
|
attachFileSystemToFunction?(fileSystemId: string, input: FileSystemAttachmentInput): Promise<CloudResource>
|
||||||
|
detachFileSystemFromFunction?(fileSystemId: string, functionName: string): Promise<CloudResource>
|
||||||
exportCertificate?(certificateId: string, passphrase: string): Promise<ExportedCertificate>
|
exportCertificate?(certificateId: string, passphrase: string): Promise<ExportedCertificate>
|
||||||
revokeCertificate?(certificateId: string, reason: CertificateRevocationReason): Promise<CloudResource>
|
revokeCertificate?(certificateId: string, reason: CertificateRevocationReason): Promise<CloudResource>
|
||||||
updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings>
|
updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
|
|||||||
import {createCloudAdapterRegistry} from './cloudProxy'
|
import {createCloudAdapterRegistry} from './cloudProxy'
|
||||||
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
|
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
|
||||||
import {isServiceType} from './cloud-spi/serviceCatalog'
|
import {isServiceType} from './cloud-spi/serviceCatalog'
|
||||||
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, CertificateActionName, DatabaseActionName, DistributionActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
import type {AutoScalingActionName, CloudProvider, CloudServiceAdapter, CloudServiceType, CertificateActionName, DatabaseActionName, DataCatalogActionName, DistributionActionName, FileSystemActionName, HttpApiActionName, QueryActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Guards the schema-to-implementation contract for every registered adapter.
|
* Guards the schema-to-implementation contract for every registered adapter.
|
||||||
@@ -81,6 +81,33 @@ const METHOD_FOR_CERTIFICATE_ACTION: Record<CertificateActionName, keyof CloudSe
|
|||||||
revoke: 'revokeCertificate',
|
revoke: 'revokeCertificate',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const METHOD_FOR_QUERY_ACTION: Record<QueryActionName, keyof CloudServiceAdapter> = {
|
||||||
|
run: 'startQuery',
|
||||||
|
results: 'getQueryResult',
|
||||||
|
stop: 'stopQuery',
|
||||||
|
history: 'listQueries',
|
||||||
|
catalog: 'describeQueryCatalog',
|
||||||
|
}
|
||||||
|
|
||||||
|
const METHOD_FOR_DATA_CATALOG_ACTION: Record<DataCatalogActionName, keyof CloudServiceAdapter> = {
|
||||||
|
createTable: 'createCatalogTable',
|
||||||
|
deleteTable: 'deleteCatalogTable',
|
||||||
|
}
|
||||||
|
|
||||||
|
const METHOD_FOR_FILE_SYSTEM_ACTION: Record<FileSystemActionName, keyof CloudServiceAdapter> = {
|
||||||
|
createMountTarget: 'createMountTarget',
|
||||||
|
deleteMountTarget: 'deleteMountTarget',
|
||||||
|
createAccessPoint: 'createAccessPoint',
|
||||||
|
deleteAccessPoint: 'deleteAccessPoint',
|
||||||
|
attachFunction: 'attachFileSystemToFunction',
|
||||||
|
detachFunction: 'detachFileSystemFromFunction',
|
||||||
|
}
|
||||||
|
|
||||||
|
const METHOD_FOR_AUTO_SCALING_ACTION: Record<AutoScalingActionName, keyof CloudServiceAdapter> = {
|
||||||
|
setCapacity: 'setAutoScalingCapacity',
|
||||||
|
terminateInstance: 'terminateAutoScalingInstance',
|
||||||
|
}
|
||||||
|
|
||||||
const METHOD_FOR_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudServiceAdapter> = {
|
const METHOD_FOR_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudServiceAdapter> = {
|
||||||
updateUser: 'updateUserPoolUser',
|
updateUser: 'updateUserPoolUser',
|
||||||
setUserGroups: 'setUserPoolUserGroups',
|
setUserGroups: 'setUserPoolUserGroups',
|
||||||
@@ -203,6 +230,42 @@ describe('registered adapters honour their schema', () => {
|
|||||||
).toBe('function')
|
).toBe('function')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoScalingClaims = (adapter.schema().capabilities?.autoScalingActions ?? []).filter((capability) => capability.status === 'available')
|
||||||
|
for (const capability of autoScalingClaims) {
|
||||||
|
const method = METHOD_FOR_AUTO_SCALING_ACTION[capability.name]
|
||||||
|
expect(
|
||||||
|
typeof adapter[method],
|
||||||
|
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||||
|
).toBe('function')
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryClaims = (adapter.schema().capabilities?.queryActions ?? []).filter((capability) => capability.status === 'available')
|
||||||
|
for (const capability of queryClaims) {
|
||||||
|
const method = METHOD_FOR_QUERY_ACTION[capability.name]
|
||||||
|
expect(
|
||||||
|
typeof adapter[method],
|
||||||
|
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||||
|
).toBe('function')
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataCatalogClaims = (adapter.schema().capabilities?.dataCatalogActions ?? []).filter((capability) => capability.status === 'available')
|
||||||
|
for (const capability of dataCatalogClaims) {
|
||||||
|
const method = METHOD_FOR_DATA_CATALOG_ACTION[capability.name]
|
||||||
|
expect(
|
||||||
|
typeof adapter[method],
|
||||||
|
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||||
|
).toBe('function')
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileSystemClaims = (adapter.schema().capabilities?.fileSystemActions ?? []).filter((capability) => capability.status === 'available')
|
||||||
|
for (const capability of fileSystemClaims) {
|
||||||
|
const method = METHOD_FOR_FILE_SYSTEM_ACTION[capability.name]
|
||||||
|
expect(
|
||||||
|
typeof adapter[method],
|
||||||
|
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||||
|
).toBe('function')
|
||||||
|
}
|
||||||
|
|
||||||
const distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available')
|
const distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available')
|
||||||
for (const capability of distributionClaims) {
|
for (const capability of distributionClaims) {
|
||||||
const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name]
|
const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name]
|
||||||
@@ -225,6 +288,10 @@ 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?.fileSystemActions ?? []),
|
||||||
|
...(adapter.schema().capabilities?.dataCatalogActions ?? []),
|
||||||
|
...(adapter.schema().capabilities?.queryActions ?? []),
|
||||||
...(adapter.schema().capabilities?.certificateActions ?? []),
|
...(adapter.schema().capabilities?.certificateActions ?? []),
|
||||||
...(adapter.schema().capabilities?.monitoringActions ?? []),
|
...(adapter.schema().capabilities?.monitoringActions ?? []),
|
||||||
...(adapter.schema().capabilities?.httpApiActions ?? []),
|
...(adapter.schema().capabilities?.httpApiActions ?? []),
|
||||||
|
|||||||
@@ -50,7 +50,12 @@ import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
|
|||||||
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
|
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
|
||||||
import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter'
|
import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter'
|
||||||
import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter'
|
import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter'
|
||||||
|
import {AwsAutoScalingAdapter} from './adapter-aws/AwsAutoScalingAdapter'
|
||||||
import {AwsCertificatesAdapter} from './adapter-aws/AwsCertificatesAdapter'
|
import {AwsCertificatesAdapter} from './adapter-aws/AwsCertificatesAdapter'
|
||||||
|
import {AwsPipesAdapter} from './adapter-aws/AwsPipesAdapter'
|
||||||
|
import {AwsFileSystemsAdapter} from './adapter-aws/AwsFileSystemsAdapter'
|
||||||
|
import {AwsDataCatalogAdapter} from './adapter-aws/AwsDataCatalogAdapter'
|
||||||
|
import {AwsQueryAdapter} from './adapter-aws/AwsQueryAdapter'
|
||||||
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'
|
||||||
@@ -104,7 +109,12 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
|||||||
new AwsUserPoolsAdapter(clients.cognito),
|
new AwsUserPoolsAdapter(clients.cognito),
|
||||||
new AwsDnsAdapter(clients.route53),
|
new AwsDnsAdapter(clients.route53),
|
||||||
new AwsCdnAdapter(clients.cloudfront, clients.s3),
|
new AwsCdnAdapter(clients.cloudfront, clients.s3),
|
||||||
|
new AwsAutoScalingAdapter(clients.autoscaling, clients.ec2),
|
||||||
new AwsCertificatesAdapter(clients.acm, resolveAccountId(accountId)),
|
new AwsCertificatesAdapter(clients.acm, resolveAccountId(accountId)),
|
||||||
|
new AwsPipesAdapter(clients.pipes),
|
||||||
|
new AwsFileSystemsAdapter(clients.efs, clients.lambda, clients.ec2),
|
||||||
|
new AwsDataCatalogAdapter(clients.glue),
|
||||||
|
new AwsQueryAdapter(clients.athena),
|
||||||
new AwsMonitoringAdapter(clients.cloudwatch),
|
new AwsMonitoringAdapter(clients.cloudwatch),
|
||||||
new AzureStorageAdapter(),
|
new AzureStorageAdapter(),
|
||||||
new AzureServiceBusAdapter(),
|
new AzureServiceBusAdapter(),
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ 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 {awsFileSystemsSchema} from '../cloud-spi/fileSystemsSchema'
|
||||||
|
import {awsDataCatalogSchema} from '../cloud-spi/dataCatalogSchema'
|
||||||
|
import {awsQuerySchema} from '../cloud-spi/querySchema'
|
||||||
import {awsCertificatesSchema} from '../cloud-spi/certificatesSchema'
|
import {awsCertificatesSchema} from '../cloud-spi/certificatesSchema'
|
||||||
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
|
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
|
||||||
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
||||||
@@ -852,6 +856,95 @@ describe('cloud schema routes', () => {
|
|||||||
expect(calls).toEqual([['pem', 'c1'], ['export', 'c1', 'secret'], ['revoke', 'c1', 'UNSPECIFIED']])
|
expect(calls).toEqual([['pem', 'c1'], ['export', 'c1', 'secret'], ['revoke', 'c1', 'UNSPECIFIED']])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('changes capacity and terminates an instance through the Auto Scaling adapter', async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const app = appWithRoutes([mockAdapter('aws', {
|
||||||
|
service: 'autoscaling',
|
||||||
|
schema: awsAutoScalingSchema,
|
||||||
|
setAutoScalingCapacity: async (id, input) => {
|
||||||
|
calls.push(['capacity', id, input])
|
||||||
|
return {id, name: id, cloud: 'aws', service: 'autoscaling', type: 'autoScalingGroup', region: null, createdAt: null, metadata: {desiredCapacity: 3}}
|
||||||
|
},
|
||||||
|
terminateAutoScalingInstance: async (id, instanceId, replace) => {
|
||||||
|
calls.push(['terminate', id, instanceId, replace])
|
||||||
|
},
|
||||||
|
})])
|
||||||
|
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
|
||||||
|
const base = '/api/clouds/aws/services/autoscaling/resources/web-asg'
|
||||||
|
|
||||||
|
const updated = await app.request(`${base}/capacity`, json('PUT', {desiredCapacity: 3}))
|
||||||
|
expect((await updated.json()).metadata.desiredCapacity).toBe(3)
|
||||||
|
expect((await app.request(`${base}/instances/i-abc/terminate`, json('POST', {replace: true}))).status).toBe(200)
|
||||||
|
expect(calls).toEqual([['capacity', 'web-asg', {desiredCapacity: 3}], ['terminate', 'web-asg', 'i-abc', true]])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('runs, reads, lists and cancels queries through the Athena adapter', async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const execution = {id: 'q1', sql: 'SELECT 1', database: 'shop', workGroup: 'analysts', state: 'SUCCEEDED', stateReason: null, statementType: 'DML', submittedAt: null, completedAt: null, runtimeMs: 5, dataScannedBytes: 0, outputLocation: 's3://r/q1.csv'}
|
||||||
|
const app = appWithRoutes([mockAdapter('aws', {
|
||||||
|
service: 'query',
|
||||||
|
schema: awsQuerySchema,
|
||||||
|
startQuery: async (id, input) => { calls.push(['start', id, input.sql, input.database]); return execution },
|
||||||
|
getQueryResult: async (id, queryId) => { calls.push(['result', id, queryId]); return {execution, columns: [{name: '_col0', type: 'integer'}], rows: [['1']], truncated: false} },
|
||||||
|
stopQuery: async (id, queryId) => { calls.push(['stop', id, queryId]) },
|
||||||
|
listQueries: async (id) => { calls.push(['list', id]); return [execution] },
|
||||||
|
describeQueryCatalog: async (database) => { calls.push(['catalog', database]); return {databases: ['shop'], database: 'shop', tables: []} },
|
||||||
|
})])
|
||||||
|
const base = '/api/clouds/aws/services/query/resources/analysts/queries'
|
||||||
|
|
||||||
|
expect((await app.request(base, {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({sql: 'SELECT 1', database: 'shop'})})).status).toBe(201)
|
||||||
|
expect((await (await app.request(`${base}/q1`)).json()).rows).toEqual([['1']])
|
||||||
|
expect((await app.request(base)).status).toBe(200)
|
||||||
|
expect((await app.request(`${base}/q1/stop`, {method: 'POST'})).status).toBe(200)
|
||||||
|
expect((await (await app.request('/api/clouds/aws/services/query/catalog?database=shop')).json()).databases).toEqual(['shop'])
|
||||||
|
expect(calls).toEqual([['start', 'analysts', 'SELECT 1', 'shop'], ['result', 'analysts', 'q1'], ['list', 'analysts'], ['stop', 'analysts', 'q1'], ['catalog', 'shop']])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates and deletes catalog tables through the Glue adapter', async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const database = {id: 'shop', name: 'shop', cloud: 'aws' as const, service: 'datacatalog' as const, type: 'glueDatabase', region: null, createdAt: null, metadata: {}}
|
||||||
|
const app = appWithRoutes([mockAdapter('aws', {
|
||||||
|
service: 'datacatalog',
|
||||||
|
schema: awsDataCatalogSchema,
|
||||||
|
createCatalogTable: async (id, input) => { calls.push(['create', id, input.name, input.format]); return database },
|
||||||
|
deleteCatalogTable: async (id, tableName) => { calls.push(['delete', id, tableName]); return database },
|
||||||
|
})])
|
||||||
|
const base = '/api/clouds/aws/services/datacatalog/resources/shop/tables'
|
||||||
|
|
||||||
|
const created = await app.request(base, {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({name: 'orders', location: 's3://data/orders/', format: 'csv', columns: [{name: 'id', type: 'int'}]})})
|
||||||
|
expect(created.status).toBe(201)
|
||||||
|
expect((await app.request(`${base}/orders`, {method: 'DELETE'})).status).toBe(200)
|
||||||
|
expect(calls).toEqual([['create', 'shop', 'orders', 'csv'], ['delete', 'shop', 'orders']])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('manages mount targets, access points and function mounts through the EFS adapter', async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const fileSystem = {id: 'fs-1', name: 'shared', cloud: 'aws' as const, service: 'filesystems' as const, type: 'fileSystem', region: null, createdAt: null, metadata: {}}
|
||||||
|
const app = appWithRoutes([mockAdapter('aws', {
|
||||||
|
service: 'filesystems',
|
||||||
|
schema: awsFileSystemsSchema,
|
||||||
|
createMountTarget: async (id, subnetId) => { calls.push(['mount', id, subnetId]); return fileSystem },
|
||||||
|
deleteMountTarget: async (id, mountTargetId) => { calls.push(['unmount', id, mountTargetId]); return fileSystem },
|
||||||
|
createAccessPoint: async (id, input) => { calls.push(['ap', id, input.path]); return fileSystem },
|
||||||
|
deleteAccessPoint: async (id, accessPointId) => { calls.push(['delete-ap', id, accessPointId]); return fileSystem },
|
||||||
|
attachFileSystemToFunction: async (id, input) => { calls.push(['attach', id, input.functionName, input.mountPath]); return fileSystem },
|
||||||
|
detachFileSystemFromFunction: async (id, functionName) => { calls.push(['detach', id, functionName]); return fileSystem },
|
||||||
|
})])
|
||||||
|
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
|
||||||
|
const base = '/api/clouds/aws/services/filesystems/resources/fs-1'
|
||||||
|
|
||||||
|
expect((await app.request(`${base}/mount-targets`, json('POST', {subnetId: 'subnet-a'}))).status).toBe(201)
|
||||||
|
expect((await app.request(`${base}/mount-targets/fsmt-1`, {method: 'DELETE'})).status).toBe(200)
|
||||||
|
expect((await app.request(`${base}/access-points`, json('POST', {path: '/app', uid: 1000, gid: 1000, permissions: '755'}))).status).toBe(201)
|
||||||
|
expect((await app.request(`${base}/access-points/fsap-1`, {method: 'DELETE'})).status).toBe(200)
|
||||||
|
expect((await app.request(`${base}/functions`, json('POST', {accessPointId: 'fsap-1', functionName: 'writer', mountPath: '/mnt/share'}))).status).toBe(200)
|
||||||
|
expect((await app.request(`${base}/functions/writer`, {method: 'DELETE'})).status).toBe(200)
|
||||||
|
expect(calls).toEqual([
|
||||||
|
['mount', 'fs-1', 'subnet-a'], ['unmount', 'fs-1', 'fsmt-1'], ['ap', 'fs-1', '/app'], ['delete-ap', 'fs-1', 'fsap-1'],
|
||||||
|
['attach', 'fs-1', 'writer', '/mnt/share'], ['detach', 'fs-1', 'writer'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
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', {
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import {Hono} from 'hono'
|
|||||||
import type {Context} from 'hono'
|
import type {Context} from 'hono'
|
||||||
import type {
|
import type {
|
||||||
AlarmStateValue,
|
AlarmStateValue,
|
||||||
|
AutoScalingCapacityInput,
|
||||||
|
AccessPointInput,
|
||||||
|
CatalogTableInput,
|
||||||
|
QueryInput,
|
||||||
|
FileSystemAttachmentInput,
|
||||||
CertificateRevocationReason,
|
CertificateRevocationReason,
|
||||||
CloudProvider,
|
CloudProvider,
|
||||||
CloudServiceType,
|
CloudServiceType,
|
||||||
@@ -553,6 +558,121 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get('/:cloud/services/query/catalog', 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).describeQueryCatalog(cloud, c.req.query('database') || undefined)))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/:cloud/services/query/resources/:id/queries', 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).listQueries(cloud, c.req.param('id'))))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/query/resources/:id/queries', 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).startQuery(cloud, c.req.param('id'), await c.req.json<QueryInput>()), 201))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/:cloud/services/query/resources/:id/queries/:queryId', 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).getQueryResult(cloud, c.req.param('id'), c.req.param('queryId'))))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/query/resources/:id/queries/:queryId/stop', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => {
|
||||||
|
await svc(c).stopQuery(cloud, c.req.param('id'), c.req.param('queryId'))
|
||||||
|
return c.json({ok: true})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/datacatalog/resources/:id/tables', 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).createCatalogTable(cloud, c.req.param('id'), await c.req.json<CatalogTableInput>()), 201))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/:cloud/services/datacatalog/resources/:id/tables/:tableName', 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).deleteCatalogTable(cloud, c.req.param('id'), c.req.param('tableName'))))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/filesystems/resources/:id/mount-targets', 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<{subnetId?: string}>()
|
||||||
|
return c.json(await svc(c).createMountTarget(cloud, c.req.param('id'), body.subnetId ?? ''), 201)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/:cloud/services/filesystems/resources/:id/mount-targets/:mountTargetId', 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).deleteMountTarget(cloud, c.req.param('id'), c.req.param('mountTargetId'))))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/filesystems/resources/:id/access-points', 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).createAccessPoint(cloud, c.req.param('id'), await c.req.json<AccessPointInput>()), 201))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/:cloud/services/filesystems/resources/:id/access-points/:accessPointId', 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).deleteAccessPoint(cloud, c.req.param('id'), c.req.param('accessPointId'))))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/filesystems/resources/:id/functions', 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).attachFileSystemToFunction(cloud, c.req.param('id'), await c.req.json<FileSystemAttachmentInput>())))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/:cloud/services/filesystems/resources/:id/functions/:functionName', 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).detachFileSystemFromFunction(cloud, c.req.param('id'), c.req.param('functionName'))))
|
||||||
|
})
|
||||||
|
|
||||||
|
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,14 @@ import type {
|
|||||||
UserPoolSignInTestResult,
|
UserPoolSignInTestResult,
|
||||||
UserPoolClientSettings,
|
UserPoolClientSettings,
|
||||||
DistributionInvalidation,
|
DistributionInvalidation,
|
||||||
|
AutoScalingCapacityInput,
|
||||||
|
AccessPointInput,
|
||||||
|
CatalogTableInput,
|
||||||
|
QueryCatalog,
|
||||||
|
QueryExecutionSummary,
|
||||||
|
QueryInput,
|
||||||
|
QueryResult,
|
||||||
|
FileSystemAttachmentInput,
|
||||||
CertificatePem,
|
CertificatePem,
|
||||||
CertificateRevocationReason,
|
CertificateRevocationReason,
|
||||||
ExportedCertificate,
|
ExportedCertificate,
|
||||||
@@ -963,6 +971,96 @@ export class CloudProxyService {
|
|||||||
return adapter.revokeCertificate(certificateId, reason)
|
return adapter.revokeCertificate(certificateId, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async startQuery(cloud: CloudProvider, workGroup: string, input: QueryInput): Promise<QueryExecutionSummary> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'query')
|
||||||
|
if (!adapter.startQuery) throw new NotSupportedError(`Running queries is not supported for ${cloud}/query`)
|
||||||
|
return adapter.startQuery(workGroup, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getQueryResult(cloud: CloudProvider, workGroup: string, queryId: string): Promise<QueryResult> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'query')
|
||||||
|
if (!adapter.getQueryResult) throw new NotSupportedError(`Query results are not supported for ${cloud}/query`)
|
||||||
|
return adapter.getQueryResult(workGroup, queryId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopQuery(cloud: CloudProvider, workGroup: string, queryId: string): Promise<void> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'query')
|
||||||
|
if (!adapter.stopQuery) throw new NotSupportedError(`Cancelling queries is not supported for ${cloud}/query`)
|
||||||
|
return adapter.stopQuery(workGroup, queryId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listQueries(cloud: CloudProvider, workGroup: string): Promise<QueryExecutionSummary[]> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'query')
|
||||||
|
if (!adapter.listQueries) throw new NotSupportedError(`Query history is not supported for ${cloud}/query`)
|
||||||
|
return adapter.listQueries(workGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
async describeQueryCatalog(cloud: CloudProvider, database?: string): Promise<QueryCatalog> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'query')
|
||||||
|
if (!adapter.describeQueryCatalog) throw new NotSupportedError(`Browsing the catalog is not supported for ${cloud}/query`)
|
||||||
|
return adapter.describeQueryCatalog(database)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCatalogTable(cloud: CloudProvider, databaseName: string, input: CatalogTableInput): Promise<CloudResource> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'datacatalog')
|
||||||
|
if (!adapter.createCatalogTable) throw new NotSupportedError(`Creating tables is not supported for ${cloud}/datacatalog`)
|
||||||
|
return adapter.createCatalogTable(databaseName, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCatalogTable(cloud: CloudProvider, databaseName: string, tableName: string): Promise<CloudResource> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'datacatalog')
|
||||||
|
if (!adapter.deleteCatalogTable) throw new NotSupportedError(`Deleting tables is not supported for ${cloud}/datacatalog`)
|
||||||
|
return adapter.deleteCatalogTable(databaseName, tableName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMountTarget(cloud: CloudProvider, fileSystemId: string, subnetId: string): Promise<CloudResource> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'filesystems')
|
||||||
|
if (!adapter.createMountTarget) throw new NotSupportedError(`Mount targets are not supported for ${cloud}/filesystems`)
|
||||||
|
return adapter.createMountTarget(fileSystemId, subnetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMountTarget(cloud: CloudProvider, fileSystemId: string, mountTargetId: string): Promise<CloudResource> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'filesystems')
|
||||||
|
if (!adapter.deleteMountTarget) throw new NotSupportedError(`Mount targets are not supported for ${cloud}/filesystems`)
|
||||||
|
return adapter.deleteMountTarget(fileSystemId, mountTargetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAccessPoint(cloud: CloudProvider, fileSystemId: string, input: AccessPointInput): Promise<CloudResource> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'filesystems')
|
||||||
|
if (!adapter.createAccessPoint) throw new NotSupportedError(`Access points are not supported for ${cloud}/filesystems`)
|
||||||
|
return adapter.createAccessPoint(fileSystemId, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAccessPoint(cloud: CloudProvider, fileSystemId: string, accessPointId: string): Promise<CloudResource> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'filesystems')
|
||||||
|
if (!adapter.deleteAccessPoint) throw new NotSupportedError(`Access points are not supported for ${cloud}/filesystems`)
|
||||||
|
return adapter.deleteAccessPoint(fileSystemId, accessPointId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async attachFileSystemToFunction(cloud: CloudProvider, fileSystemId: string, input: FileSystemAttachmentInput): Promise<CloudResource> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'filesystems')
|
||||||
|
if (!adapter.attachFileSystemToFunction) throw new NotSupportedError(`Attaching file systems is not supported for ${cloud}/filesystems`)
|
||||||
|
return adapter.attachFileSystemToFunction(fileSystemId, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async detachFileSystemFromFunction(cloud: CloudProvider, fileSystemId: string, functionName: string): Promise<CloudResource> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'filesystems')
|
||||||
|
if (!adapter.detachFileSystemFromFunction) throw new NotSupportedError(`Detaching file systems is not supported for ${cloud}/filesystems`)
|
||||||
|
return adapter.detachFileSystemFromFunction(fileSystemId, functionName)
|
||||||
|
}
|
||||||
|
|
||||||
|
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`)
|
||||||
|
|||||||
@@ -155,6 +155,29 @@ export const apiEndpointKeys = {
|
|||||||
export: "clouds.services.certificates.export",
|
export: "clouds.services.certificates.export",
|
||||||
revoke: "clouds.services.certificates.revoke",
|
revoke: "clouds.services.certificates.revoke",
|
||||||
},
|
},
|
||||||
|
query: {
|
||||||
|
catalog: "clouds.services.query.catalog",
|
||||||
|
queries: "clouds.services.query.queries",
|
||||||
|
start: "clouds.services.query.start",
|
||||||
|
result: "clouds.services.query.result",
|
||||||
|
stop: "clouds.services.query.stop",
|
||||||
|
},
|
||||||
|
datacatalog: {
|
||||||
|
tables: "clouds.services.datacatalog.tables",
|
||||||
|
table: "clouds.services.datacatalog.table",
|
||||||
|
},
|
||||||
|
filesystems: {
|
||||||
|
mountTargets: "clouds.services.filesystems.mountTargets",
|
||||||
|
mountTarget: "clouds.services.filesystems.mountTarget",
|
||||||
|
accessPoints: "clouds.services.filesystems.accessPoints",
|
||||||
|
accessPoint: "clouds.services.filesystems.accessPoint",
|
||||||
|
functions: "clouds.services.filesystems.functions",
|
||||||
|
function: "clouds.services.filesystems.function",
|
||||||
|
},
|
||||||
|
autoscaling: {
|
||||||
|
capacity: "clouds.services.autoscaling.capacity",
|
||||||
|
terminate: "clouds.services.autoscaling.instances.terminate",
|
||||||
|
},
|
||||||
cdn: {
|
cdn: {
|
||||||
enabled: "clouds.services.cdn.enabled",
|
enabled: "clouds.services.cdn.enabled",
|
||||||
invalidations: "clouds.services.cdn.invalidations",
|
invalidations: "clouds.services.cdn.invalidations",
|
||||||
@@ -952,6 +975,111 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.query.catalog,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/query/catalog",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.query.queries,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/query/resources/:id/queries",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.query.start,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/query/resources/:id/queries",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.query.result,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/query/resources/:id/queries/:queryId",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.query.stop,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/query/resources/:id/queries/:queryId/stop",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.datacatalog.tables,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/datacatalog/resources/:id/tables",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.datacatalog.table,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/datacatalog/resources/:id/tables/:tableName",
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.filesystems.mountTargets,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/filesystems/resources/:id/mount-targets",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.filesystems.mountTarget,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/filesystems/resources/:id/mount-targets/:mountTargetId",
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.filesystems.accessPoints,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/filesystems/resources/:id/access-points",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.filesystems.accessPoint,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/filesystems/resources/:id/access-points/:accessPointId",
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.filesystems.functions,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/filesystems/resources/:id/functions",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.filesystems.function,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/filesystems/resources/:id/functions/:functionName",
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
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,14 @@ import type {
|
|||||||
UserPoolSignInTestResult,
|
UserPoolSignInTestResult,
|
||||||
UserPoolClientSettings,
|
UserPoolClientSettings,
|
||||||
DistributionInvalidation,
|
DistributionInvalidation,
|
||||||
|
AutoScalingCapacityInput,
|
||||||
|
AccessPointInput,
|
||||||
|
CatalogTableInput,
|
||||||
|
QueryCatalog,
|
||||||
|
QueryExecutionSummary,
|
||||||
|
QueryInput,
|
||||||
|
QueryResult,
|
||||||
|
FileSystemAttachmentInput,
|
||||||
CertificatePem,
|
CertificatePem,
|
||||||
CertificateRevocationReason,
|
CertificateRevocationReason,
|
||||||
ExportedCertificate,
|
ExportedCertificate,
|
||||||
@@ -1720,6 +1728,201 @@ export async function revokeCertificate(
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function describeQueryCatalog(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
database?: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<QueryCatalog> {
|
||||||
|
const res = await apiClient.call<QueryCatalog>(
|
||||||
|
apiEndpointKeys.clouds.query.catalog,
|
||||||
|
requestOptions(cloud, "query", { signal, params: { database } }),
|
||||||
|
{ cloud },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listQueries(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
workGroup: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<QueryExecutionSummary[]> {
|
||||||
|
const res = await apiClient.call<QueryExecutionSummary[]>(
|
||||||
|
apiEndpointKeys.clouds.query.queries,
|
||||||
|
requestOptions(cloud, "query", { signal }),
|
||||||
|
{ cloud, id: workGroup },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startQuery(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
workGroup: string,
|
||||||
|
input: QueryInput,
|
||||||
|
): Promise<QueryExecutionSummary> {
|
||||||
|
const res = await apiClient.call<QueryExecutionSummary, QueryInput>(
|
||||||
|
apiEndpointKeys.clouds.query.start,
|
||||||
|
requestOptions(cloud, "query", { body: input }),
|
||||||
|
{ cloud, id: workGroup },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQueryResult(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
workGroup: string,
|
||||||
|
queryId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<QueryResult> {
|
||||||
|
const res = await apiClient.call<QueryResult>(
|
||||||
|
apiEndpointKeys.clouds.query.result,
|
||||||
|
requestOptions(cloud, "query", { signal }),
|
||||||
|
{ cloud, id: workGroup, queryId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopQuery(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
workGroup: string,
|
||||||
|
queryId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.call<void>(
|
||||||
|
apiEndpointKeys.clouds.query.stop,
|
||||||
|
requestOptions(cloud, "query"),
|
||||||
|
{ cloud, id: workGroup, queryId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCatalogTable(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
databaseName: string,
|
||||||
|
input: CatalogTableInput,
|
||||||
|
): Promise<CloudResource> {
|
||||||
|
const res = await apiClient.call<CloudResource, CatalogTableInput>(
|
||||||
|
apiEndpointKeys.clouds.datacatalog.tables,
|
||||||
|
requestOptions(cloud, "datacatalog", { body: input }),
|
||||||
|
{ cloud, id: databaseName },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCatalogTable(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
databaseName: string,
|
||||||
|
tableName: string,
|
||||||
|
): Promise<CloudResource> {
|
||||||
|
const res = await apiClient.call<CloudResource>(
|
||||||
|
apiEndpointKeys.clouds.datacatalog.table,
|
||||||
|
requestOptions(cloud, "datacatalog"),
|
||||||
|
{ cloud, id: databaseName, tableName },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMountTarget(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
fileSystemId: string,
|
||||||
|
subnetId: string,
|
||||||
|
): Promise<CloudResource> {
|
||||||
|
const res = await apiClient.call<CloudResource, { subnetId: string }>(
|
||||||
|
apiEndpointKeys.clouds.filesystems.mountTargets,
|
||||||
|
requestOptions(cloud, "filesystems", { body: { subnetId } }),
|
||||||
|
{ cloud, id: fileSystemId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMountTarget(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
fileSystemId: string,
|
||||||
|
mountTargetId: string,
|
||||||
|
): Promise<CloudResource> {
|
||||||
|
const res = await apiClient.call<CloudResource>(
|
||||||
|
apiEndpointKeys.clouds.filesystems.mountTarget,
|
||||||
|
requestOptions(cloud, "filesystems"),
|
||||||
|
{ cloud, id: fileSystemId, mountTargetId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAccessPoint(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
fileSystemId: string,
|
||||||
|
input: AccessPointInput,
|
||||||
|
): Promise<CloudResource> {
|
||||||
|
const res = await apiClient.call<CloudResource, AccessPointInput>(
|
||||||
|
apiEndpointKeys.clouds.filesystems.accessPoints,
|
||||||
|
requestOptions(cloud, "filesystems", { body: input }),
|
||||||
|
{ cloud, id: fileSystemId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAccessPoint(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
fileSystemId: string,
|
||||||
|
accessPointId: string,
|
||||||
|
): Promise<CloudResource> {
|
||||||
|
const res = await apiClient.call<CloudResource>(
|
||||||
|
apiEndpointKeys.clouds.filesystems.accessPoint,
|
||||||
|
requestOptions(cloud, "filesystems"),
|
||||||
|
{ cloud, id: fileSystemId, accessPointId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function attachFileSystemToFunction(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
fileSystemId: string,
|
||||||
|
input: FileSystemAttachmentInput,
|
||||||
|
): Promise<CloudResource> {
|
||||||
|
const res = await apiClient.call<CloudResource, FileSystemAttachmentInput>(
|
||||||
|
apiEndpointKeys.clouds.filesystems.functions,
|
||||||
|
requestOptions(cloud, "filesystems", { body: input }),
|
||||||
|
{ cloud, id: fileSystemId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detachFileSystemFromFunction(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
fileSystemId: string,
|
||||||
|
functionName: string,
|
||||||
|
): Promise<CloudResource> {
|
||||||
|
const res = await apiClient.call<CloudResource>(
|
||||||
|
apiEndpointKeys.clouds.filesystems.function,
|
||||||
|
requestOptions(cloud, "filesystems"),
|
||||||
|
{ cloud, id: fileSystemId, functionName },
|
||||||
|
);
|
||||||
|
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'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,92 @@
|
|||||||
|
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 {DataCatalogPanel} from './DataCatalogPanel'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
getCloudResource: vi.fn(),
|
||||||
|
createCatalogTable: vi.fn(),
|
||||||
|
deleteCatalogTable: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
|
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||||
|
|
||||||
|
function database(tables: unknown[] = [{
|
||||||
|
name: 'orders', description: null, format: 'csv', location: 's3://data/orders/', delimiter: ';', headerRow: true,
|
||||||
|
columns: [{name: 'id', type: 'int'}, {name: 'amount', type: 'double'}], partitionKeys: [], createdAt: null, updatedAt: null,
|
||||||
|
}]): CloudResource {
|
||||||
|
return {id: 'shop', name: 'shop', cloud: 'aws', service: 'datacatalog', type: 'glueDatabase', region: null, createdAt: null, metadata: {description: 'Orders', tableCount: tables.length, tables}}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel(resource: CloudResource) {
|
||||||
|
api.getCloudResource.mockResolvedValue(resource)
|
||||||
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
return render(<MemoryRouter><QueryClientProvider client={client}><DataCatalogPanel resource={resource} runtimeReachable/></QueryClientProvider></MemoryRouter>)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
api.createCatalogTable.mockResolvedValue(database())
|
||||||
|
api.deleteCatalogTable.mockResolvedValue(database([]))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lists tables with their format, location and columns', async () => {
|
||||||
|
renderPanel(database())
|
||||||
|
|
||||||
|
const row = (await screen.findByText('orders')).closest('tr') as HTMLElement
|
||||||
|
expect(within(row).getByText('s3://data/orders/')).toBeInTheDocument()
|
||||||
|
expect(within(row).getByText('semicolon-separated, header row')).toBeInTheDocument()
|
||||||
|
expect(within(row).getByText('amount')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', {name: 'Query in Athena'})).toHaveAttribute('href', '/cloud-explorer/aws/query')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a CSV table from the columns entered', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel(database([]))
|
||||||
|
|
||||||
|
const form = await screen.findByRole('form', {name: 'Create table'})
|
||||||
|
await user.type(within(form).getByLabelText('Name'), 'events')
|
||||||
|
const location = within(form).getByLabelText('S3 location')
|
||||||
|
await user.clear(location)
|
||||||
|
await user.type(location, 's3://data/events/')
|
||||||
|
await user.type(within(form).getByLabelText('Column 1 name'), 'id')
|
||||||
|
await user.selectOptions(within(form).getByLabelText('Column 1 type'), 'int')
|
||||||
|
await user.click(within(form).getByRole('button', {name: 'Add column'}))
|
||||||
|
await user.type(within(form).getByLabelText('Column 2 name'), 'kind')
|
||||||
|
await user.click(within(form).getByRole('button', {name: 'Create table'}))
|
||||||
|
|
||||||
|
expect(api.createCatalogTable).toHaveBeenCalledWith('aws', 'shop', {
|
||||||
|
name: 'events', location: 's3://data/events/', format: 'csv', delimiter: ',', headerRow: true,
|
||||||
|
columns: [{name: 'id', type: 'int'}, {name: 'kind', type: 'string'}],
|
||||||
|
})
|
||||||
|
expect(await screen.findByText('Created table events. Athena can query it now.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('JSON tables need no delimiter or header', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel(database([]))
|
||||||
|
|
||||||
|
const form = await screen.findByRole('form', {name: 'Create table'})
|
||||||
|
await user.selectOptions(within(form).getByLabelText('Format'), 'json')
|
||||||
|
expect(within(form).queryByLabelText('Delimiter')).not.toBeInTheDocument()
|
||||||
|
await user.type(within(form).getByLabelText('Name'), 'events')
|
||||||
|
await user.type(within(form).getByLabelText('S3 location'), 'data/events/')
|
||||||
|
await user.type(within(form).getByLabelText('Column 1 name'), 'kind')
|
||||||
|
await user.click(within(form).getByRole('button', {name: 'Create table'}))
|
||||||
|
|
||||||
|
expect(api.createCatalogTable).toHaveBeenCalledWith('aws', 'shop', {name: 'events', location: 's3://data/events/', format: 'json', columns: [{name: 'kind', type: 'string'}]})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deletes a table after confirming', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
renderPanel(database())
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Delete table orders'}))
|
||||||
|
expect(api.deleteCatalogTable).toHaveBeenCalledWith('aws', 'shop', 'orders')
|
||||||
|
expect(await screen.findByText('Deleted table orders. Its files stay in S3.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import {Plus, RefreshCw, X} from 'lucide-react'
|
||||||
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
import {useEffect, useState} from 'react'
|
||||||
|
import {Link} from 'react-router-dom'
|
||||||
|
import {createCatalogTable, deleteCatalogTable, getCloudResource} from '@/api/cloudProxyClient'
|
||||||
|
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
|
||||||
|
import {useAccountId} from '@/lib/accountStore'
|
||||||
|
import {formatDateTime} from '@/lib/format'
|
||||||
|
import type {CatalogTableInput, CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
resource?: CloudResource
|
||||||
|
runtimeReachable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Column = {name: string; type: string}
|
||||||
|
type CatalogTable = {
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
format: string
|
||||||
|
location: string | null
|
||||||
|
delimiter: string | null
|
||||||
|
headerRow: boolean
|
||||||
|
columns: Column[]
|
||||||
|
partitionKeys: Column[]
|
||||||
|
updatedAt: string | null
|
||||||
|
createdAt: string | null
|
||||||
|
}
|
||||||
|
type TableForm = {name: string; location: string; format: CatalogTableInput['format']; delimiter: string; headerRow: boolean; columns: Column[]}
|
||||||
|
|
||||||
|
/** Matches the types the API accepts; decimal(p,s) can also be typed in by hand through the API. */
|
||||||
|
const COLUMN_TYPES = ['string', 'int', 'bigint', 'double', 'float', 'boolean', 'date', 'timestamp', 'decimal(10,2)']
|
||||||
|
const FORMAT_LABELS: Record<string, string> = {csv: 'CSV', json: 'JSON lines', parquet: 'Parquet', iceberg: 'Iceberg'}
|
||||||
|
const DELIMITER_NAMES: Record<string, string> = {',': 'comma-separated', ';': 'semicolon-separated', '\t': 'tab-separated', '|': 'pipe-separated'}
|
||||||
|
const delimiterLabel = (delimiter: string | null) => DELIMITER_NAMES[delimiter ?? ','] ?? `separated by "${delimiter}"`
|
||||||
|
const emptyForm = (): TableForm => ({name: '', location: 's3://', format: 'csv', delimiter: ',', headerRow: true, columns: [{name: '', type: 'string'}]})
|
||||||
|
|
||||||
|
export function DataCatalogPanel({resource, runtimeReachable}: Props) {
|
||||||
|
const accountId = useAccountId()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form, setForm] = useState<TableForm>(emptyForm)
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const detailKey = ['glue-database', accountId, resource?.id]
|
||||||
|
|
||||||
|
const detailQuery = useQuery({
|
||||||
|
queryKey: detailKey,
|
||||||
|
queryFn: ({signal}) => getCloudResource('aws', 'datacatalog', resource?.id ?? '', signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNotice('')
|
||||||
|
setForm(emptyForm())
|
||||||
|
}, [resource?.id])
|
||||||
|
|
||||||
|
const settle = (updated: CloudResource, message: string) => {
|
||||||
|
queryClient.setQueryData(detailKey, updated)
|
||||||
|
setNotice(message)
|
||||||
|
return queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources('aws', 'datacatalog')})
|
||||||
|
}
|
||||||
|
const createTable = useMutation({
|
||||||
|
mutationFn: () => createCatalogTable('aws', resource?.id ?? '', {
|
||||||
|
name: form.name.trim(),
|
||||||
|
location: form.location.trim(),
|
||||||
|
format: form.format,
|
||||||
|
...(form.format === 'csv' ? {delimiter: form.delimiter, headerRow: form.headerRow} : {}),
|
||||||
|
columns: form.columns.map((column) => ({name: column.name.trim(), type: column.type})),
|
||||||
|
}),
|
||||||
|
onSuccess: (updated) => {
|
||||||
|
const name = form.name.trim()
|
||||||
|
setForm(emptyForm())
|
||||||
|
return settle(updated, `Created table ${name}. Athena can query it now.`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const deleteTable = useMutation({
|
||||||
|
mutationFn: (tableName: string) => deleteCatalogTable('aws', resource?.id ?? '', tableName),
|
||||||
|
onSuccess: (updated, tableName) => settle(updated, `Deleted table ${tableName}. Its files stay in S3.`),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return <section className="table-panel"><div className="empty compact"><h3>Select a database</h3><p>Choose a database to see its tables, or describe files in S3 as a new table.</p></div></section>
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = detailQuery.data ?? resource
|
||||||
|
const tables = Array.isArray(database.metadata.tables) ? database.metadata.tables as CatalogTable[] : []
|
||||||
|
const disabled = !runtimeReachable || createTable.isPending || deleteTable.isPending
|
||||||
|
const error = detailQuery.error ?? createTable.error ?? deleteTable.error
|
||||||
|
const setColumn = (index: number, patch: Partial<Column>) => setForm({...form, columns: form.columns.map((column, i) => i === index ? {...column, ...patch} : column)})
|
||||||
|
|
||||||
|
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Database ${database.name}`}>
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div><p className="eyebrow">Glue database</p><span className="muted">{database.metadata.description ? String(database.metadata.description) : 'Tables here describe files in S3 so Athena can query them.'}</span></div>
|
||||||
|
<div className="cdn-actions">
|
||||||
|
{tables.length > 0 && <Link className="button" to="/cloud-explorer/aws/query">Query in Athena</Link>}
|
||||||
|
<button className="button" type="button" disabled={!runtimeReachable || detailQuery.isFetching} onClick={() => { void detailQuery.refetch() }}><RefreshCw size={14}/>{detailQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Tables</p><span className="muted">{tables.length === 1 ? '1 table' : `${tables.length} tables`}</span></div></div>
|
||||||
|
{tables.length === 0 ? <p className="muted compact-text catalog-note">No tables yet. Describe a folder of CSV, JSON or Parquet files below.</p>
|
||||||
|
: <div className="cognito-table"><table className="table"><thead><tr><th>Table</th><th>Format</th><th>Location</th><th>Columns</th><th>Updated</th><th aria-label="Actions"/></tr></thead><tbody>{tables.map((table) => (
|
||||||
|
<tr key={table.name}>
|
||||||
|
<td className="mono">{table.name}{table.description && <><br/><small className="muted">{table.description}</small></>}</td>
|
||||||
|
<td>{FORMAT_LABELS[table.format] ?? table.format}{table.format === 'csv' && <><br/><small className="muted">{delimiterLabel(table.delimiter)}{table.headerRow ? ', header row' : ''}</small></>}</td>
|
||||||
|
<td className="mono catalog-location">{table.location ?? '—'}</td>
|
||||||
|
<td className="catalog-columns">{table.columns.map((column) => <span key={column.name}><code>{column.name}</code> <small className="muted">{column.type}</small></span>)}
|
||||||
|
{table.partitionKeys.length > 0 && <small className="muted">partitioned by {table.partitionKeys.map((key) => key.name).join(', ')}</small>}</td>
|
||||||
|
<td>{formatDateTime(table.updatedAt ?? table.createdAt) ?? '—'}</td>
|
||||||
|
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Delete table ${table.name}`} onClick={() => {
|
||||||
|
setNotice('')
|
||||||
|
if (window.confirm(`Delete table ${table.name}? Its files stay in S3.`)) deleteTable.mutate(table.name)
|
||||||
|
}}>Delete</button></td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody></table></div>}
|
||||||
|
|
||||||
|
<form className="catalog-form" aria-label="Create table" onSubmit={(event) => { event.preventDefault(); setNotice(''); createTable.mutate() }}>
|
||||||
|
<p className="eyebrow">New table</p>
|
||||||
|
<div className="dynamic-form catalog-form-fields">
|
||||||
|
<label className="dynamic-field"><span>Name</span><input className="input mono" value={form.name} onChange={(event) => setForm({...form, name: event.target.value})} disabled={disabled} required pattern="[a-z_][a-z0-9_]*" title="Lowercase letters, numbers and underscores"/></label>
|
||||||
|
<label className="dynamic-field catalog-span"><span>S3 location</span><input className="input mono" value={form.location} onChange={(event) => setForm({...form, location: event.target.value})} disabled={disabled} required pattern="s3://[a-z0-9.\-]{3,63}(/.*)?" title="A folder such as s3://my-bucket/orders/"/></label>
|
||||||
|
<label className="dynamic-field"><span>Format</span><select className="input" value={form.format} onChange={(event) => setForm({...form, format: event.target.value as TableForm['format']})} disabled={disabled}>
|
||||||
|
<option value="csv">CSV</option><option value="json">JSON lines</option><option value="parquet">Parquet</option>
|
||||||
|
</select></label>
|
||||||
|
{form.format === 'csv' && <>
|
||||||
|
<label className="dynamic-field"><span>Delimiter</span><input className="input mono" value={form.delimiter} maxLength={1} onChange={(event) => setForm({...form, delimiter: event.target.value})} disabled={disabled} required/></label>
|
||||||
|
<label className="dynamic-field catalog-check"><input type="checkbox" checked={form.headerRow} onChange={(event) => setForm({...form, headerRow: event.target.checked})} disabled={disabled}/><span>First line is a header</span></label>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
<fieldset className="catalog-columns-editor" disabled={disabled}>
|
||||||
|
<legend>Columns</legend>
|
||||||
|
{form.columns.map((column, index) => <div className="catalog-column-row" key={index}>
|
||||||
|
<input className="input mono" aria-label={`Column ${index + 1} name`} value={column.name} onChange={(event) => setColumn(index, {name: event.target.value})} required pattern="[a-z_][a-z0-9_]*" title="Lowercase letters, numbers and underscores"/>
|
||||||
|
<select className="input" aria-label={`Column ${index + 1} type`} value={column.type} onChange={(event) => setColumn(index, {type: event.target.value})}>
|
||||||
|
{COLUMN_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
|
||||||
|
</select>
|
||||||
|
<button className="button compact" type="button" aria-label={`Remove column ${index + 1}`} disabled={form.columns.length === 1} onClick={() => setForm({...form, columns: form.columns.filter((_, i) => i !== index)})}><X size={14}/></button>
|
||||||
|
</div>)}
|
||||||
|
<button className="button compact" type="button" onClick={() => setForm({...form, columns: [...form.columns, {name: '', type: 'string'}]})}><Plus size={14}/>Add column</button>
|
||||||
|
</fieldset>
|
||||||
|
<p className="muted compact-text">List the columns in the order they appear in the files. For JSON, use the field names.</p>
|
||||||
|
<div><button className="button primary" type="submit" disabled={disabled}>{createTable.isPending ? 'Creating' : 'Create table'}</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>
|
||||||
|
}
|
||||||
@@ -68,7 +68,12 @@ 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 { FileSystemPanel } from "@/components/FileSystemPanel";
|
||||||
|
import { DataCatalogPanel } from "@/components/DataCatalogPanel";
|
||||||
|
import { QueryPanel } from "@/components/QueryPanel";
|
||||||
import { CertificatePanel } from "@/components/CertificatePanel";
|
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";
|
||||||
@@ -991,6 +996,21 @@ export function DynamicResourceView({
|
|||||||
{service === "certificates" && cloud === "aws" && (
|
{service === "certificates" && cloud === "aws" && (
|
||||||
<CertificatePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
<CertificatePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
)}
|
)}
|
||||||
|
{service === "pipes" && cloud === "aws" && (
|
||||||
|
<PipePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
|
)}
|
||||||
|
{service === "autoscaling" && cloud === "aws" && (
|
||||||
|
<AutoScalingPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
|
)}
|
||||||
|
{service === "filesystems" && cloud === "aws" && (
|
||||||
|
<FileSystemPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
|
)}
|
||||||
|
{service === "datacatalog" && cloud === "aws" && (
|
||||||
|
<DataCatalogPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
|
)}
|
||||||
|
{service === "query" && cloud === "aws" && (
|
||||||
|
<QueryPanel 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,104 @@
|
|||||||
|
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 {FileSystemPanel} from './FileSystemPanel'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
getCloudResource: vi.fn(),
|
||||||
|
listCloudResources: vi.fn(),
|
||||||
|
createMountTarget: vi.fn(),
|
||||||
|
deleteMountTarget: vi.fn(),
|
||||||
|
createAccessPoint: vi.fn(),
|
||||||
|
deleteAccessPoint: vi.fn(),
|
||||||
|
attachFileSystemToFunction: vi.fn(),
|
||||||
|
detachFileSystemFromFunction: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
|
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||||
|
|
||||||
|
function fileSystem(metadata: Record<string, unknown> = {}): CloudResource {
|
||||||
|
return {
|
||||||
|
id: 'fs-1', name: 'shared', cloud: 'aws', service: 'filesystems', type: 'fileSystem', region: 'us-east-1', createdAt: null, status: 'available',
|
||||||
|
metadata: {
|
||||||
|
fileSystemId: 'fs-1', throughputMode: 'bursting', encrypted: true,
|
||||||
|
mountTargets: [{id: 'fsmt-1', subnetId: 'subnet-a', availabilityZone: 'us-east-1a', ipAddress: '10.0.0.10', state: 'available'}],
|
||||||
|
accessPoints: [{id: 'fsap-1', arn: 'arn:aws:elasticfilesystem:us-east-1:000000000000:access-point/fsap-1', name: 'app', path: '/app', uid: 1000, gid: 1000, permissions: '755', state: 'available'}],
|
||||||
|
functions: [{name: 'writer', accessPointId: 'fsap-1', mountPath: '/mnt/share'}],
|
||||||
|
...metadata,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel(resource: CloudResource) {
|
||||||
|
api.getCloudResource.mockResolvedValue(resource)
|
||||||
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
return render(<MemoryRouter><QueryClientProvider client={client}><FileSystemPanel resource={resource} runtimeReachable/></QueryClientProvider></MemoryRouter>)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
api.listCloudResources.mockResolvedValue([{id: 'reader', name: 'reader'}, {id: 'writer', name: 'writer'}])
|
||||||
|
for (const fn of [api.createMountTarget, api.deleteMountTarget, api.createAccessPoint, api.deleteAccessPoint, api.attachFileSystemToFunction, api.detachFileSystemFromFunction]) {
|
||||||
|
fn.mockResolvedValue(fileSystem())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows mount targets, access points and the functions that mount the file system', async () => {
|
||||||
|
renderPanel(fileSystem())
|
||||||
|
|
||||||
|
expect(await screen.findByText('fsmt-1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('fsap-1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', {name: 'writer'})).toHaveAttribute('href', '/cloud-explorer/aws/serverless')
|
||||||
|
expect(screen.getByText('/mnt/share')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('adds a mount target and creates an access point', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel(fileSystem())
|
||||||
|
|
||||||
|
const mountForm = await screen.findByRole('form', {name: 'Add mount target'})
|
||||||
|
await user.type(within(mountForm).getByLabelText('Subnet ID'), 'subnet-b')
|
||||||
|
await user.click(within(mountForm).getByRole('button', {name: 'Add mount target'}))
|
||||||
|
expect(api.createMountTarget).toHaveBeenCalledWith('aws', 'fs-1', 'subnet-b')
|
||||||
|
expect(await screen.findByText('Added a mount target in subnet-b.')).toBeInTheDocument()
|
||||||
|
|
||||||
|
const accessPointForm = screen.getByRole('form', {name: 'Create access point'})
|
||||||
|
const path = within(accessPointForm).getByLabelText('Root directory')
|
||||||
|
await user.clear(path)
|
||||||
|
await user.type(path, '/uploads')
|
||||||
|
await user.click(within(accessPointForm).getByRole('button', {name: 'Create access point'}))
|
||||||
|
expect(api.createAccessPoint).toHaveBeenCalledWith('aws', 'fs-1', {path: '/uploads', uid: 1000, gid: 1000, permissions: '755'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('attaches the file system to a function through an access point', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel(fileSystem())
|
||||||
|
|
||||||
|
const form = await screen.findByRole('form', {name: 'Attach to a function'})
|
||||||
|
await user.selectOptions(within(form).getByLabelText('Function'), await within(form).findByRole('option', {name: 'reader'}))
|
||||||
|
await user.selectOptions(within(form).getByLabelText('Access point'), 'fsap-1')
|
||||||
|
await user.click(within(form).getByRole('button', {name: 'Attach'}))
|
||||||
|
|
||||||
|
expect(api.attachFileSystemToFunction).toHaveBeenCalledWith('aws', 'fs-1', {accessPointId: 'fsap-1', functionName: 'reader', mountPath: '/mnt/data'})
|
||||||
|
expect(await screen.findByText('reader now sees this file system at /mnt/data.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detaches a function after confirming', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
renderPanel(fileSystem())
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Detach writer'}))
|
||||||
|
expect(api.detachFileSystemFromFunction).toHaveBeenCalledWith('aws', 'fs-1', 'writer')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cannot attach until the file system has a mount target', async () => {
|
||||||
|
renderPanel(fileSystem({mountTargets: [], functions: []}))
|
||||||
|
|
||||||
|
expect(await screen.findByText(/No mount targets yet/)).toBeInTheDocument()
|
||||||
|
expect(within(screen.getByRole('form', {name: 'Attach to a function'})).getByRole('button', {name: 'Attach'})).toBeDisabled()
|
||||||
|
})
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import {RefreshCw} from 'lucide-react'
|
||||||
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
import {useEffect, useState} from 'react'
|
||||||
|
import {Link} from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
attachFileSystemToFunction,
|
||||||
|
createAccessPoint,
|
||||||
|
createMountTarget,
|
||||||
|
deleteAccessPoint,
|
||||||
|
deleteMountTarget,
|
||||||
|
detachFileSystemFromFunction,
|
||||||
|
getCloudResource,
|
||||||
|
listCloudResources,
|
||||||
|
} 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
|
||||||
|
}
|
||||||
|
|
||||||
|
type MountTarget = {id: string; subnetId: string | null; availabilityZone: string | null; ipAddress: string | null; state: string | null}
|
||||||
|
type AccessPoint = {id: string; arn: string | null; name: string | null; path: string; uid: number | null; gid: number | null; permissions: string | null; state: string | null}
|
||||||
|
type MountedFunction = {name: string; accessPointId: string | null; mountPath: string | null}
|
||||||
|
|
||||||
|
const EMPTY_ACCESS_POINT = {name: '', path: '/', uid: '1000', gid: '1000', permissions: '755'}
|
||||||
|
|
||||||
|
export function FileSystemPanel({resource, runtimeReachable}: Props) {
|
||||||
|
const accountId = useAccountId()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [subnetId, setSubnetId] = useState('')
|
||||||
|
const [accessPointForm, setAccessPointForm] = useState(EMPTY_ACCESS_POINT)
|
||||||
|
const [attachForm, setAttachForm] = useState({accessPointId: '', functionName: '', mountPath: '/mnt/data'})
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const detailKey = ['efs-file-system', accountId, resource?.id]
|
||||||
|
|
||||||
|
const detailQuery = useQuery({
|
||||||
|
queryKey: detailKey,
|
||||||
|
queryFn: ({signal}) => getCloudResource('aws', 'filesystems', resource?.id ?? '', signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
const functionsQuery = useQuery({
|
||||||
|
queryKey: cloudQueryKeys.resources('aws', 'serverless'),
|
||||||
|
queryFn: ({signal}) => listCloudResources('aws', 'serverless', undefined, signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNotice('')
|
||||||
|
setSubnetId('')
|
||||||
|
setAccessPointForm(EMPTY_ACCESS_POINT)
|
||||||
|
setAttachForm({accessPointId: '', functionName: '', mountPath: '/mnt/data'})
|
||||||
|
}, [resource?.id])
|
||||||
|
|
||||||
|
const settle = (updated: CloudResource, message: string) => {
|
||||||
|
queryClient.setQueryData(detailKey, updated)
|
||||||
|
setNotice(message)
|
||||||
|
return queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources('aws', 'filesystems')})
|
||||||
|
}
|
||||||
|
const id = resource?.id ?? ''
|
||||||
|
const addMountTarget = useMutation({
|
||||||
|
mutationFn: () => createMountTarget('aws', id, subnetId.trim()),
|
||||||
|
onSuccess: (updated) => { setSubnetId(''); return settle(updated, `Added a mount target in ${subnetId.trim()}.`) },
|
||||||
|
})
|
||||||
|
const removeMountTarget = useMutation({
|
||||||
|
mutationFn: (mountTargetId: string) => deleteMountTarget('aws', id, mountTargetId),
|
||||||
|
onSuccess: (updated, mountTargetId) => settle(updated, `Removed mount target ${mountTargetId}.`),
|
||||||
|
})
|
||||||
|
const addAccessPoint = useMutation({
|
||||||
|
mutationFn: () => createAccessPoint('aws', id, {
|
||||||
|
...(accessPointForm.name.trim() ? {name: accessPointForm.name.trim()} : {}),
|
||||||
|
path: accessPointForm.path.trim(),
|
||||||
|
uid: Number(accessPointForm.uid),
|
||||||
|
gid: Number(accessPointForm.gid),
|
||||||
|
permissions: accessPointForm.permissions.trim(),
|
||||||
|
}),
|
||||||
|
onSuccess: (updated) => { setAccessPointForm(EMPTY_ACCESS_POINT); return settle(updated, `Created an access point at ${accessPointForm.path.trim()}.`) },
|
||||||
|
})
|
||||||
|
const removeAccessPoint = useMutation({
|
||||||
|
mutationFn: (accessPointId: string) => deleteAccessPoint('aws', id, accessPointId),
|
||||||
|
onSuccess: (updated, accessPointId) => settle(updated, `Deleted access point ${accessPointId}.`),
|
||||||
|
})
|
||||||
|
const attach = useMutation({
|
||||||
|
mutationFn: () => attachFileSystemToFunction('aws', id, {...attachForm, mountPath: attachForm.mountPath.trim()}),
|
||||||
|
onSuccess: (updated) => settle(updated, `${attachForm.functionName} now sees this file system at ${attachForm.mountPath.trim()}.`),
|
||||||
|
})
|
||||||
|
const detach = useMutation({
|
||||||
|
mutationFn: (functionName: string) => detachFileSystemFromFunction('aws', id, functionName),
|
||||||
|
onSuccess: (updated, functionName) => settle(updated, `Detached ${functionName}.`),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return <section className="table-panel"><div className="empty compact"><h3>Select a file system</h3><p>Choose a file system to manage its mount targets and access points, and attach it to Lambda functions.</p></div></section>
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileSystem = detailQuery.data ?? resource
|
||||||
|
const metadata = fileSystem.metadata
|
||||||
|
const mountTargets = Array.isArray(metadata.mountTargets) ? metadata.mountTargets as MountTarget[] : []
|
||||||
|
const accessPoints = Array.isArray(metadata.accessPoints) ? metadata.accessPoints as AccessPoint[] : []
|
||||||
|
const mounted = Array.isArray(metadata.functions) ? metadata.functions as MountedFunction[] : []
|
||||||
|
const functionNames = (functionsQuery.data ?? []).map((fn) => fn.name).sort()
|
||||||
|
const mutations = [addMountTarget, removeMountTarget, addAccessPoint, removeAccessPoint, attach, detach]
|
||||||
|
const disabled = !runtimeReachable || mutations.some((mutation) => mutation.isPending)
|
||||||
|
const error = detailQuery.error ?? mutations.find((mutation) => mutation.error)?.error
|
||||||
|
const accessPointLabel = (accessPointId: string | null) => {
|
||||||
|
const accessPoint = accessPoints.find((candidate) => candidate.id === accessPointId)
|
||||||
|
return accessPoint ? `${accessPoint.name ?? accessPoint.id} (${accessPoint.path})` : accessPointId ?? '—'
|
||||||
|
}
|
||||||
|
const run = (action: () => void) => { setNotice(''); action() }
|
||||||
|
|
||||||
|
return <section className="table-panel" style={{marginTop: 16}} aria-label={`File system ${fileSystem.name}`}>
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div><p className="eyebrow">EFS file system</p><span className="muted mono">{String(metadata.fileSystemId ?? fileSystem.id)}</span><span className="muted"> · {String(metadata.throughputMode ?? '')} · {metadata.encrypted ? 'encrypted' : 'not encrypted'}</span></div>
|
||||||
|
<div className="cdn-actions">
|
||||||
|
<button className="button" type="button" disabled={!runtimeReachable || detailQuery.isFetching} onClick={() => { void detailQuery.refetch() }}><RefreshCw size={14}/>{detailQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Mount targets</p><span className="muted">Functions and tasks reach the file system through a mount target in their subnet.</span></div></div>
|
||||||
|
{mountTargets.length === 0 ? <p className="muted compact-text efs-note">No mount targets yet. Add one before attaching the file system to a function.</p>
|
||||||
|
: <div className="cognito-table"><table className="table"><thead><tr><th>Mount target</th><th>Subnet</th><th>Zone</th><th>IP address</th><th>State</th><th aria-label="Actions"/></tr></thead><tbody>{mountTargets.map((mountTarget) => (
|
||||||
|
<tr key={mountTarget.id}>
|
||||||
|
<td className="mono">{mountTarget.id}</td>
|
||||||
|
<td className="mono">{mountTarget.subnetId ?? '—'}</td>
|
||||||
|
<td>{mountTarget.availabilityZone ?? '—'}</td>
|
||||||
|
<td className="mono">{mountTarget.ipAddress ?? '—'}</td>
|
||||||
|
<td><code>{mountTarget.state ?? '—'}</code></td>
|
||||||
|
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Remove ${mountTarget.id}`} onClick={() => run(() => {
|
||||||
|
if (window.confirm(`Remove mount target ${mountTarget.id}?`)) removeMountTarget.mutate(mountTarget.id)
|
||||||
|
})}>Remove</button></td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody></table></div>}
|
||||||
|
<form className="dynamic-form efs-inline-form" aria-label="Add mount target" onSubmit={(event) => { event.preventDefault(); run(() => addMountTarget.mutate()) }}>
|
||||||
|
<label className="dynamic-field"><span>Subnet ID</span><input className="input mono" value={subnetId} placeholder="subnet-…" onChange={(event) => setSubnetId(event.target.value)} disabled={disabled} required/></label>
|
||||||
|
<div><button className="button" type="submit" disabled={disabled || !subnetId.trim()}>{addMountTarget.isPending ? 'Adding' : 'Add mount target'}</button></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Access points</p><span className="muted">An entry point into one directory, as a fixed POSIX user. Lambda mounts a file system through an access point.</span></div></div>
|
||||||
|
{accessPoints.length === 0 ? <p className="muted compact-text efs-note">No access points yet.</p>
|
||||||
|
: <div className="cognito-table"><table className="table"><thead><tr><th>Access point</th><th>Root directory</th><th>POSIX user</th><th>State</th><th aria-label="Actions"/></tr></thead><tbody>{accessPoints.map((accessPoint) => (
|
||||||
|
<tr key={accessPoint.id}>
|
||||||
|
<td>{accessPoint.name && <>{accessPoint.name}<br/></>}<small className="mono">{accessPoint.id}</small></td>
|
||||||
|
<td className="mono">{accessPoint.path}{accessPoint.permissions ? <small className="muted"> ({accessPoint.permissions})</small> : null}</td>
|
||||||
|
<td className="mono">{accessPoint.uid ?? '—'}:{accessPoint.gid ?? '—'}</td>
|
||||||
|
<td><code>{accessPoint.state ?? '—'}</code></td>
|
||||||
|
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Delete ${accessPoint.id}`} onClick={() => run(() => {
|
||||||
|
if (window.confirm(`Delete access point ${accessPoint.id}? Files stay in the file system.`)) removeAccessPoint.mutate(accessPoint.id)
|
||||||
|
})}>Delete</button></td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody></table></div>}
|
||||||
|
<form className="dynamic-form efs-access-point-form" aria-label="Create access point" onSubmit={(event) => { event.preventDefault(); run(() => addAccessPoint.mutate()) }}>
|
||||||
|
<label className="dynamic-field"><span>Name</span><input className="input" value={accessPointForm.name} onChange={(event) => setAccessPointForm({...accessPointForm, name: event.target.value})} disabled={disabled}/></label>
|
||||||
|
<label className="dynamic-field"><span>Root directory</span><input className="input mono" value={accessPointForm.path} onChange={(event) => setAccessPointForm({...accessPointForm, path: event.target.value})} disabled={disabled} required pattern="/.*" title="Start with /"/></label>
|
||||||
|
<label className="dynamic-field"><span>User ID</span><input className="input" type="number" min={0} value={accessPointForm.uid} onChange={(event) => setAccessPointForm({...accessPointForm, uid: event.target.value})} disabled={disabled} required/></label>
|
||||||
|
<label className="dynamic-field"><span>Group ID</span><input className="input" type="number" min={0} value={accessPointForm.gid} onChange={(event) => setAccessPointForm({...accessPointForm, gid: event.target.value})} disabled={disabled} required/></label>
|
||||||
|
<label className="dynamic-field"><span>Permissions</span><input className="input mono" value={accessPointForm.permissions} onChange={(event) => setAccessPointForm({...accessPointForm, permissions: event.target.value})} disabled={disabled} required pattern="[0-7]{3,4}" title="Octal, such as 755"/></label>
|
||||||
|
<div><button className="button" type="submit" disabled={disabled}>{addAccessPoint.isPending ? 'Creating' : 'Create access point'}</button></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Lambda functions</p><span className="muted">Functions that mount this file system share its files. A function can mount one file system.</span></div></div>
|
||||||
|
{mounted.length === 0 ? <p className="muted compact-text efs-note">No function mounts this file system.</p>
|
||||||
|
: <div className="cognito-table"><table className="table"><thead><tr><th>Function</th><th>Mount path</th><th>Access point</th><th aria-label="Actions"/></tr></thead><tbody>{mounted.map((fn) => (
|
||||||
|
<tr key={fn.name}>
|
||||||
|
<td><Link to="/cloud-explorer/aws/serverless" className="mono">{fn.name}</Link></td>
|
||||||
|
<td className="mono">{fn.mountPath ?? '—'}</td>
|
||||||
|
<td>{accessPointLabel(fn.accessPointId)}</td>
|
||||||
|
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Detach ${fn.name}`} onClick={() => run(() => {
|
||||||
|
if (window.confirm(`Detach the file system from ${fn.name}?`)) detach.mutate(fn.name)
|
||||||
|
})}>Detach</button></td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody></table></div>}
|
||||||
|
{accessPoints.length > 0 && <form className="dynamic-form efs-access-point-form" aria-label="Attach to a function" onSubmit={(event) => { event.preventDefault(); run(() => attach.mutate()) }}>
|
||||||
|
<label className="dynamic-field"><span>Function</span><select className="input" value={attachForm.functionName} onChange={(event) => setAttachForm({...attachForm, functionName: event.target.value})} disabled={disabled} required>
|
||||||
|
<option value="">{functionNames.length === 0 ? 'No functions yet' : 'Choose a function'}</option>
|
||||||
|
{functionNames.map((name) => <option key={name} value={name}>{name}</option>)}
|
||||||
|
</select></label>
|
||||||
|
<label className="dynamic-field"><span>Access point</span><select className="input" value={attachForm.accessPointId} onChange={(event) => setAttachForm({...attachForm, accessPointId: event.target.value})} disabled={disabled} required>
|
||||||
|
<option value="">Choose an access point</option>
|
||||||
|
{accessPoints.map((accessPoint) => <option key={accessPoint.id} value={accessPoint.id}>{accessPointLabel(accessPoint.id)}</option>)}
|
||||||
|
</select></label>
|
||||||
|
<label className="dynamic-field"><span>Mount path</span><input className="input mono" value={attachForm.mountPath} onChange={(event) => setAttachForm({...attachForm, mountPath: event.target.value})} disabled={disabled} required pattern="/mnt/[a-zA-Z0-9\-_.]+" title="Under /mnt, such as /mnt/data"/></label>
|
||||||
|
<div><button className="button" type="submit" disabled={disabled || mountTargets.length === 0} title={mountTargets.length === 0 ? 'Add a mount target first' : undefined}>{attach.isPending ? 'Attaching' : 'Attach'}</button></div>
|
||||||
|
</form>}
|
||||||
|
{accessPoints.length > 0 && <p className="muted compact-text efs-note">Lambda needs a VPC to mount a file system. A function not yet in one is put in this file system's mount target subnets with the default security group.</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>
|
||||||
|
}
|
||||||
@@ -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 : ''
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
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 {QueryPanel} from './QueryPanel'
|
||||||
|
import type {CloudResource, QueryExecutionSummary} from '@/types/resource'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
describeQueryCatalog: vi.fn(),
|
||||||
|
getCloudResource: vi.fn(),
|
||||||
|
listQueries: vi.fn(),
|
||||||
|
startQuery: vi.fn(),
|
||||||
|
getQueryResult: vi.fn(),
|
||||||
|
stopQuery: vi.fn(),
|
||||||
|
storageObjectDownloadUrl: vi.fn((_cloud: string, bucket: string, key: string) => `/download/${bucket}/${key}`),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
|
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||||
|
|
||||||
|
const workGroup: CloudResource = {id: 'analysts', name: 'analysts', cloud: 'aws', service: 'query', type: 'athenaWorkGroup', region: null, createdAt: null, status: 'ENABLED', metadata: {outputLocation: 's3://results/'}}
|
||||||
|
|
||||||
|
function execution(overrides: Partial<QueryExecutionSummary> = {}): QueryExecutionSummary {
|
||||||
|
return {
|
||||||
|
id: 'q1', sql: 'SELECT * FROM orders LIMIT 10', database: 'shop', workGroup: 'analysts', state: 'SUCCEEDED', stateReason: null, statementType: 'DML',
|
||||||
|
submittedAt: '2026-10-25T21:00:00.000Z', completedAt: null, runtimeMs: 42, dataScannedBytes: 0, outputLocation: 's3://results/q1.csv', ...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel() {
|
||||||
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
return render(<MemoryRouter><QueryClientProvider client={client}><QueryPanel resource={workGroup} runtimeReachable/></QueryClientProvider></MemoryRouter>)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
api.getCloudResource.mockResolvedValue({...workGroup, metadata: {outputLocation: 's3://results/athena/'}})
|
||||||
|
api.describeQueryCatalog.mockResolvedValue({databases: ['logs', 'shop'], database: 'logs', tables: [{name: 'orders', columns: [{name: 'id', type: 'int'}, {name: 'amount', type: 'double'}]}]})
|
||||||
|
api.listQueries.mockResolvedValue([])
|
||||||
|
api.startQuery.mockResolvedValue(execution({state: 'RUNNING'}))
|
||||||
|
api.getQueryResult.mockResolvedValue({execution: execution(), columns: [{name: 'id', type: 'integer'}, {name: 'amount', type: 'double'}], rows: [['1', '10.5'], ['2', null]], truncated: false})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('starts from a preview of the first table and lists its columns', async () => {
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
expect(await screen.findByDisplayValue('SELECT * FROM orders LIMIT 10')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('amount')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Database')).toHaveValue('logs')
|
||||||
|
expect(await screen.findByText('s3://results/athena/')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('runs the query in the workgroup and shows its rows', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await screen.findByDisplayValue('SELECT * FROM orders LIMIT 10')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Run'}))
|
||||||
|
|
||||||
|
expect(api.startQuery).toHaveBeenCalledWith('aws', 'analysts', {sql: 'SELECT * FROM orders LIMIT 10', database: 'logs'})
|
||||||
|
expect(await screen.findByText('10.5')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('NULL')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('2 rows')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', {name: 'Download CSV'})).toHaveAttribute('href', '/download/results/q1.csv')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows why a query failed', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
api.getQueryResult.mockResolvedValue({execution: execution({state: 'FAILED', stateReason: 'Table nope does not exist'}), columns: [], rows: [], truncated: false})
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
const sql = await screen.findByDisplayValue('SELECT * FROM orders LIMIT 10')
|
||||||
|
await user.clear(sql)
|
||||||
|
await user.type(sql, 'SELECT * FROM nope')
|
||||||
|
await user.keyboard('{Control>}{Enter}{/Control}')
|
||||||
|
|
||||||
|
expect(api.startQuery).toHaveBeenCalledWith('aws', 'analysts', {sql: 'SELECT * FROM nope', database: 'logs'})
|
||||||
|
expect(await screen.findByText('Table nope does not exist')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link', {name: 'Download CSV'})).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('opens a query from the history', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
api.listQueries.mockResolvedValue([execution({id: 'q0', sql: 'SELECT count(*) FROM orders', database: 'shop'})])
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'SELECT count(*) FROM orders'}))
|
||||||
|
expect(screen.getByLabelText('SQL')).toHaveValue('SELECT count(*) FROM orders')
|
||||||
|
expect(api.getQueryResult).toHaveBeenCalledWith('aws', 'analysts', 'q0', expect.anything())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('points to the Glue Data Catalog when there is nothing to query', async () => {
|
||||||
|
api.describeQueryCatalog.mockResolvedValue({databases: [], database: null, tables: []})
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
const link = await screen.findByRole('link', {name: 'Glue Data Catalog'})
|
||||||
|
expect(link).toHaveAttribute('href', '/cloud-explorer/aws/datacatalog')
|
||||||
|
expect(within(screen.getByRole('form', {name: 'Query'})).getByRole('button', {name: 'Run'})).toBeDisabled()
|
||||||
|
})
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import {Download, Play, RefreshCw, Square} from 'lucide-react'
|
||||||
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
import {useEffect, useMemo, useState} from 'react'
|
||||||
|
import {Link} from 'react-router-dom'
|
||||||
|
import {describeQueryCatalog, getCloudResource, getQueryResult, listQueries, startQuery, stopQuery, storageObjectDownloadUrl} from '@/api/cloudProxyClient'
|
||||||
|
import {useAccountId} from '@/lib/accountStore'
|
||||||
|
import {formatDateTime} from '@/lib/format'
|
||||||
|
import type {CloudResource, QueryExecutionSummary} from '@/types/resource'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
resource?: CloudResource
|
||||||
|
runtimeReachable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Athena reports a running query's state; it is re-read this often until it finishes. */
|
||||||
|
const RUNNING_POLL_MS = 1000
|
||||||
|
const FINISHED = new Set(['SUCCEEDED', 'FAILED', 'CANCELLED'])
|
||||||
|
|
||||||
|
export function QueryPanel({resource, runtimeReachable}: Props) {
|
||||||
|
const accountId = useAccountId()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const workGroup = resource?.id ?? ''
|
||||||
|
const [database, setDatabase] = useState('')
|
||||||
|
const [sql, setSql] = useState('')
|
||||||
|
const [activeQueryId, setActiveQueryId] = useState<string | null>(null)
|
||||||
|
const [seededFor, setSeededFor] = useState<string | null>(null)
|
||||||
|
const historyKey = useMemo(() => ['athena-history', accountId, workGroup], [accountId, workGroup])
|
||||||
|
|
||||||
|
const workGroupQuery = useQuery({
|
||||||
|
queryKey: ['athena-workgroup', accountId, workGroup],
|
||||||
|
queryFn: ({signal}) => getCloudResource('aws', 'query', workGroup, signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
const catalogQuery = useQuery({
|
||||||
|
queryKey: ['athena-catalog', accountId, database],
|
||||||
|
queryFn: ({signal}) => describeQueryCatalog('aws', database || undefined, signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
const historyQuery = useQuery({
|
||||||
|
queryKey: historyKey,
|
||||||
|
queryFn: ({signal}) => listQueries('aws', workGroup, signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
const resultQuery = useQuery({
|
||||||
|
queryKey: ['athena-result', accountId, workGroup, activeQueryId],
|
||||||
|
queryFn: ({signal}) => getQueryResult('aws', workGroup, activeQueryId ?? '', signal),
|
||||||
|
enabled: Boolean(activeQueryId) && runtimeReachable,
|
||||||
|
refetchInterval: (query) => query.state.data && !FINISHED.has(query.state.data.execution.state) ? RUNNING_POLL_MS : false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const catalog = catalogQuery.data
|
||||||
|
const chosenDatabase = database || catalog?.database || ''
|
||||||
|
const firstTable = catalog?.tables[0]?.name
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveQueryId(null)
|
||||||
|
setSql('')
|
||||||
|
}, [workGroup])
|
||||||
|
// Start each workgroup from a query that runs as-is, the way the Athena console opens on a table.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!firstTable || seededFor === workGroup) return
|
||||||
|
setSeededFor(workGroup)
|
||||||
|
setSql((current) => current || `SELECT * FROM ${quote(firstTable)} LIMIT 10`)
|
||||||
|
}, [firstTable, seededFor, workGroup])
|
||||||
|
|
||||||
|
const execution = resultQuery.data?.execution
|
||||||
|
const finished = execution ? FINISHED.has(execution.state) : true
|
||||||
|
const executionState = execution?.state
|
||||||
|
useEffect(() => {
|
||||||
|
if (executionState && FINISHED.has(executionState)) void queryClient.invalidateQueries({queryKey: historyKey})
|
||||||
|
}, [executionState, historyKey, queryClient])
|
||||||
|
|
||||||
|
const run = useMutation({
|
||||||
|
mutationFn: () => startQuery('aws', workGroup, {sql, ...(chosenDatabase ? {database: chosenDatabase} : {})}),
|
||||||
|
onSuccess: (started) => {
|
||||||
|
setActiveQueryId(started.id)
|
||||||
|
return queryClient.invalidateQueries({queryKey: historyKey})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const cancel = useMutation({
|
||||||
|
mutationFn: () => stopQuery('aws', workGroup, activeQueryId ?? ''),
|
||||||
|
onSuccess: () => resultQuery.refetch(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return <section className="table-panel"><div className="empty compact"><h3>Select a workgroup</h3><p>Choose a workgroup to run SQL against the tables in the Glue Data Catalog.</p></div></section>
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputLocation = (workGroupQuery.data ?? resource).metadata.outputLocation
|
||||||
|
const disabled = !runtimeReachable || run.isPending
|
||||||
|
const result = resultQuery.data
|
||||||
|
const error = run.error ?? cancel.error ?? resultQuery.error ?? catalogQuery.error
|
||||||
|
const history = historyQuery.data ?? []
|
||||||
|
const download = execution?.outputLocation ? s3Location(execution.outputLocation) : null
|
||||||
|
const submit = () => { if (sql.trim() && !disabled) run.mutate() }
|
||||||
|
const openHistory = (entry: QueryExecutionSummary) => {
|
||||||
|
setActiveQueryId(entry.id)
|
||||||
|
setSql(entry.sql)
|
||||||
|
if (entry.database) setDatabase(entry.database)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Workgroup ${resource.name}`}>
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div><p className="eyebrow">Athena query editor</p><span className="muted">Workgroup {resource.name}{outputLocation ? <> · results in <code>{String(outputLocation)}</code></> : null}</span></div>
|
||||||
|
<div className="cdn-actions">
|
||||||
|
<button className="button" type="button" disabled={!runtimeReachable || catalogQuery.isFetching} onClick={() => { void catalogQuery.refetch(); void historyQuery.refetch() }}><RefreshCw size={14}/>{catalogQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="query-layout">
|
||||||
|
<aside className="query-catalog" aria-label="Data catalog">
|
||||||
|
<label className="dynamic-field"><span>Database</span>
|
||||||
|
<select className="input" value={chosenDatabase} onChange={(event) => setDatabase(event.target.value)} disabled={!runtimeReachable || !catalog?.databases.length}>
|
||||||
|
{!catalog?.databases.length && <option value="">No databases</option>}
|
||||||
|
{catalog?.databases.map((name) => <option key={name} value={name}>{name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{catalog && catalog.databases.length === 0 && <p className="muted compact-text">Athena queries tables in the <Link to="/cloud-explorer/aws/datacatalog">Glue Data Catalog</Link>. Create a database and a table there first.</p>}
|
||||||
|
{catalog && catalog.tables.length === 0 && catalog.databases.length > 0 && <p className="muted compact-text">No tables in {chosenDatabase}. Add one in the <Link to="/cloud-explorer/aws/datacatalog">Glue Data Catalog</Link>.</p>}
|
||||||
|
<ul className="query-tables">{catalog?.tables.map((table) => (
|
||||||
|
<li key={table.name}>
|
||||||
|
<details>
|
||||||
|
<summary><span className="mono">{table.name}</span></summary>
|
||||||
|
<ul>{table.columns.map((column) => <li key={column.name}><code>{column.name}</code> <small className="muted">{column.type}</small></li>)}</ul>
|
||||||
|
</details>
|
||||||
|
<button className="button compact" type="button" aria-label={`Preview ${table.name}`} disabled={disabled} onClick={() => setSql(`SELECT * FROM ${quote(table.name)} LIMIT 10`)}>Preview</button>
|
||||||
|
</li>
|
||||||
|
))}</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="query-editor">
|
||||||
|
<form aria-label="Query" onSubmit={(event) => { event.preventDefault(); submit() }}>
|
||||||
|
<label className="dynamic-field"><span>SQL</span>
|
||||||
|
<textarea className="textarea mono query-sql" value={sql} spellCheck={false} rows={7} placeholder="SELECT * FROM orders LIMIT 10" onChange={(event) => setSql(event.target.value)}
|
||||||
|
onKeyDown={(event) => { if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); submit() } }}/>
|
||||||
|
</label>
|
||||||
|
<div className="query-actions">
|
||||||
|
<button className="button primary" type="submit" disabled={disabled || !sql.trim()}><Play size={14}/>{run.isPending ? 'Starting' : 'Run'}</button>
|
||||||
|
{execution && !finished && <button className="button" type="button" disabled={cancel.isPending} onClick={() => cancel.mutate()}><Square size={14}/>Cancel</button>}
|
||||||
|
<small className="muted">Ctrl+Enter runs the query. Unqualified table names resolve in {chosenDatabase || 'the chosen database'}.</small>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{execution && <div className="query-status" role="status">
|
||||||
|
<code className={`query-state query-state--${execution.state.toLowerCase()}`}>{execution.state}</code>
|
||||||
|
{execution.runtimeMs !== null && finished && <span className="muted">Run time {formatRuntime(execution.runtimeMs)}</span>}
|
||||||
|
{result && execution.state === 'SUCCEEDED' && <span className="muted">{result.rows.length === 1 ? '1 row' : `${result.rows.length} rows`}{result.truncated ? ' shown; the file has more' : ''}</span>}
|
||||||
|
{download && execution.state === 'SUCCEEDED' && <a className="button compact" href={storageObjectDownloadUrl('aws', download.bucket, download.key)} download><Download size={14}/>Download CSV</a>}
|
||||||
|
</div>}
|
||||||
|
{execution?.state === 'FAILED' && execution.stateReason && <pre className="query-error" role="alert">{execution.stateReason}</pre>}
|
||||||
|
|
||||||
|
{result && execution?.state === 'SUCCEEDED' && (result.columns.length === 0
|
||||||
|
? <p className="muted compact-text">The statement ran and returned no rows.</p>
|
||||||
|
: <div className="cognito-table query-results"><table className="table"><thead><tr>{result.columns.map((column, index) => <th key={`${column.name}-${index}`}>{column.name}</th>)}</tr></thead>
|
||||||
|
<tbody>{result.rows.map((row, rowIndex) => <tr key={rowIndex}>{row.map((cell, cellIndex) => <td key={cellIndex} className="mono">{cell ?? <span className="muted">NULL</span>}</td>)}</tr>)}</tbody></table></div>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Recent queries</p><span className="muted">The latest queries run in this workgroup.</span></div></div>
|
||||||
|
{history.length === 0 ? <p className="muted compact-text query-note">No queries yet.</p>
|
||||||
|
: <div className="cognito-table"><table className="table"><thead><tr><th>Submitted</th><th>State</th><th>Query</th><th>Run time</th></tr></thead><tbody>{history.map((entry) => (
|
||||||
|
<tr key={entry.id} aria-current={entry.id === activeQueryId ? 'true' : undefined}>
|
||||||
|
<td>{formatDateTime(entry.submittedAt) ?? '—'}</td>
|
||||||
|
<td><code className={`query-state query-state--${entry.state.toLowerCase()}`}>{entry.state}</code></td>
|
||||||
|
<td><button className="link-button mono query-history-sql" type="button" onClick={() => openHistory(entry)} title="Open this query">{entry.sql}</button></td>
|
||||||
|
<td>{entry.runtimeMs !== null ? formatRuntime(entry.runtimeMs) : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody></table></div>}
|
||||||
|
|
||||||
|
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quotes a table name only when Athena would need it, as the Athena console does. */
|
||||||
|
function quote(name: string): string {
|
||||||
|
return /^[a-z_][a-z0-9_]*$/.test(name) ? name : `"${name.replace(/"/g, '""')}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRuntime(ms: number): string {
|
||||||
|
return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)} s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function s3Location(uri: string): {bucket: string; key: string} | null {
|
||||||
|
const match = /^s3:\/\/([^/]+)\/(.+)$/.exec(uri)
|
||||||
|
return match ? {bucket: match[1], key: match[2]} : null
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
Earth,
|
Earth,
|
||||||
FileBadge,
|
FileBadge,
|
||||||
|
FolderTree,
|
||||||
Globe,
|
Globe,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Hammer,
|
Hammer,
|
||||||
@@ -16,6 +17,8 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Network,
|
Network,
|
||||||
Radio,
|
Radio,
|
||||||
|
Scaling,
|
||||||
|
SearchCode,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Scale,
|
Scale,
|
||||||
Server,
|
Server,
|
||||||
@@ -37,6 +40,10 @@ 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 efsIcon from '../../../../aws-icons-svg/Storage/EFS.svg'
|
||||||
|
import glueIcon from '../../../../aws-icons-svg/Analytics/Glue.svg'
|
||||||
|
import athenaIcon from '../../../../aws-icons-svg/Analytics/Athena.svg'
|
||||||
import certificateManagerIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Certificate-Manager.svg'
|
import certificateManagerIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Certificate-Manager.svg'
|
||||||
import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg'
|
import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg'
|
||||||
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
|
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
|
||||||
@@ -89,6 +96,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,
|
||||||
@@ -97,6 +105,10 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
|||||||
userpools: UsersRound,
|
userpools: UsersRound,
|
||||||
dns: Globe,
|
dns: Globe,
|
||||||
cdn: Earth,
|
cdn: Earth,
|
||||||
|
autoscaling: Scaling,
|
||||||
|
filesystems: FolderTree,
|
||||||
|
datacatalog: Table2,
|
||||||
|
query: SearchCode,
|
||||||
certificates: FileBadge,
|
certificates: FileBadge,
|
||||||
monitoring: Activity,
|
monitoring: Activity,
|
||||||
loadbalancing: Scale,
|
loadbalancing: Scale,
|
||||||
@@ -124,6 +136,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,
|
||||||
@@ -145,6 +158,10 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
|||||||
userpools: cognitoIcon,
|
userpools: cognitoIcon,
|
||||||
dns: route53Icon,
|
dns: route53Icon,
|
||||||
cdn: cloudFrontIcon,
|
cdn: cloudFrontIcon,
|
||||||
|
autoscaling: autoScalingIcon,
|
||||||
|
filesystems: efsIcon,
|
||||||
|
datacatalog: glueIcon,
|
||||||
|
query: athenaIcon,
|
||||||
certificates: certificateManagerIcon,
|
certificates: certificateManagerIcon,
|
||||||
monitoring: cloudWatchIcon,
|
monitoring: cloudWatchIcon,
|
||||||
workflows: stepFunctionsIcon,
|
workflows: stepFunctionsIcon,
|
||||||
|
|||||||
@@ -6616,3 +6616,62 @@ tr[aria-current="true"] td { background: var(--hover-md); }
|
|||||||
.certificate-pem { display: grid; gap: 12px; padding: 0 16px 16px; }
|
.certificate-pem { display: grid; gap: 12px; padding: 0 16px 16px; }
|
||||||
.certificate-pem-block { display: grid; gap: 6px; min-width: 0; }
|
.certificate-pem-block { display: grid; gap: 6px; min-width: 0; }
|
||||||
.certificate-pem-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
.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; }
|
||||||
|
|
||||||
|
/* EFS: short inline forms under each table, row actions on the right. */
|
||||||
|
.efs-note { padding: 0 16px 12px; margin: 0; }
|
||||||
|
.efs-inline-form { grid-template-columns: minmax(180px, 320px) auto; align-items: end; padding: 4px 16px 12px; }
|
||||||
|
.efs-access-point-form { grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); align-items: end; padding: 4px 16px 12px; }
|
||||||
|
.efs-row-actions { text-align: right; }
|
||||||
|
|
||||||
|
/* Glue Data Catalog: column chips in the tables list, a compact column editor. */
|
||||||
|
.catalog-note { padding: 0 16px 12px; margin: 0; }
|
||||||
|
.catalog-location { overflow-wrap: anywhere; }
|
||||||
|
.catalog-columns { display: flex; flex-wrap: wrap; gap: 4px 10px; }
|
||||||
|
.catalog-columns > small { flex-basis: 100%; }
|
||||||
|
.catalog-form { display: grid; gap: 10px; padding: 12px 16px; border-top: 1px solid var(--border); }
|
||||||
|
.catalog-form p { margin: 0; }
|
||||||
|
.catalog-form-fields { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); align-items: end; }
|
||||||
|
.catalog-span { grid-column: span 2; }
|
||||||
|
.catalog-check { display: flex; align-items: center; gap: 8px; min-height: 30px; }
|
||||||
|
.catalog-check input { width: auto; }
|
||||||
|
.catalog-columns-editor { display: grid; gap: 6px; border: 0; padding: 0; margin: 0; min-width: 0; }
|
||||||
|
.catalog-columns-editor legend { color: var(--text-2); font-size: 11px; font-weight: 600; text-transform: uppercase; margin-bottom: 4px; }
|
||||||
|
.catalog-column-row { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) auto; gap: 6px; max-width: 560px; }
|
||||||
|
.catalog-column-row .input { width: 100%; min-width: 0; }
|
||||||
|
.catalog-columns-editor > .button { justify-self: start; }
|
||||||
|
@media (max-width: 640px) { .catalog-span { grid-column: auto; } }
|
||||||
|
|
||||||
|
/* Athena: catalog beside the editor, results below, finished/failed states coloured by their badge. */
|
||||||
|
.query-layout { display: grid; grid-template-columns: minmax(200px, 260px) minmax(0, 1fr); gap: 16px; padding: 12px 16px; border-top: 1px solid var(--border); }
|
||||||
|
.query-catalog { display: grid; gap: 10px; align-content: start; min-width: 0; }
|
||||||
|
.query-catalog p { margin: 0; }
|
||||||
|
.query-tables { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
||||||
|
.query-tables > li { display: flex; align-items: flex-start; justify-content: space-between; gap: 6px; }
|
||||||
|
.query-tables details { min-width: 0; flex: 1; }
|
||||||
|
.query-tables summary { cursor: pointer; overflow-wrap: anywhere; }
|
||||||
|
.query-tables details ul { list-style: none; margin: 4px 0 0 14px; padding: 0; display: grid; gap: 2px; }
|
||||||
|
.query-editor { display: grid; gap: 10px; align-content: start; min-width: 0; }
|
||||||
|
.query-editor form { display: grid; gap: 8px; }
|
||||||
|
.query-sql { min-height: 140px; resize: vertical; }
|
||||||
|
.query-actions, .query-status { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||||
|
.query-state--succeeded { color: var(--status-ok); }
|
||||||
|
.query-state--failed, .query-state--cancelled { color: var(--status-error); }
|
||||||
|
.query-error { margin: 0; padding: 8px 10px; border-radius: 4px; background: var(--raised); white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; }
|
||||||
|
.query-results { max-height: 420px; overflow: auto; }
|
||||||
|
.query-note { padding: 0 16px 12px; margin: 0; }
|
||||||
|
.query-history-sql { text-align: left; max-width: 640px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
||||||
|
@media (max-width: 760px) { .query-layout { grid-template-columns: minmax(0, 1fr); } }
|
||||||
|
|||||||
@@ -356,6 +356,74 @@ export type CertificateRevocationReason =
|
|||||||
| 'CESSATION_OF_OPERATION'
|
| 'CESSATION_OF_OPERATION'
|
||||||
| 'PRIVILEGE_WITHDRAWN'
|
| 'PRIVILEGE_WITHDRAWN'
|
||||||
|
|
||||||
|
export interface AutoScalingCapacityInput {
|
||||||
|
minSize?: number
|
||||||
|
desiredCapacity?: number
|
||||||
|
maxSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryInput {
|
||||||
|
sql: string
|
||||||
|
database?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryExecutionSummary {
|
||||||
|
id: string
|
||||||
|
sql: string
|
||||||
|
database: string | null
|
||||||
|
workGroup: string | null
|
||||||
|
state: string
|
||||||
|
stateReason: string | null
|
||||||
|
statementType: string | null
|
||||||
|
submittedAt: string | null
|
||||||
|
completedAt: string | null
|
||||||
|
runtimeMs: number | null
|
||||||
|
dataScannedBytes: number | null
|
||||||
|
outputLocation: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryResult {
|
||||||
|
execution: QueryExecutionSummary
|
||||||
|
columns: Array<{name: string; type: string | null}>
|
||||||
|
rows: Array<Array<string | null>>
|
||||||
|
truncated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryCatalog {
|
||||||
|
databases: string[]
|
||||||
|
database: string | null
|
||||||
|
tables: Array<{name: string; columns: Array<{name: string; type: string}>}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogColumnInput {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogTableInput {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
location: string
|
||||||
|
format: 'csv' | 'json' | 'parquet'
|
||||||
|
delimiter?: string
|
||||||
|
headerRow?: boolean
|
||||||
|
columns: CatalogColumnInput[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessPointInput {
|
||||||
|
name?: string
|
||||||
|
path: string
|
||||||
|
uid: number
|
||||||
|
gid: number
|
||||||
|
permissions: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileSystemAttachmentInput {
|
||||||
|
accessPointId: string
|
||||||
|
functionName: string
|
||||||
|
mountPath: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface DistributionInvalidation {
|
export interface DistributionInvalidation {
|
||||||
id: string
|
id: string
|
||||||
status: string
|
status: string
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ export type KubernetesActionName =
|
|||||||
| 'deleteFargateProfile'
|
| 'deleteFargateProfile'
|
||||||
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
|
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
|
||||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||||
|
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
|
||||||
export type CertificateActionName = 'download' | 'export' | 'revoke'
|
export type CertificateActionName = 'download' | 'export' | 'revoke'
|
||||||
|
export type QueryActionName = 'run' | 'results' | 'stop' | 'history' | 'catalog'
|
||||||
|
export type DataCatalogActionName = 'createTable' | 'deleteTable'
|
||||||
|
export type FileSystemActionName = 'createMountTarget' | 'deleteMountTarget' | 'createAccessPoint' | 'deleteAccessPoint' | 'attachFunction' | 'detachFunction'
|
||||||
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'
|
||||||
@@ -92,7 +96,11 @@ export interface ServiceSchema {
|
|||||||
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
|
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
|
||||||
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
|
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
|
||||||
distributionActions?: Array<CapabilitySchema<DistributionActionName>>
|
distributionActions?: Array<CapabilitySchema<DistributionActionName>>
|
||||||
|
autoScalingActions?: Array<CapabilitySchema<AutoScalingActionName>>
|
||||||
certificateActions?: Array<CapabilitySchema<CertificateActionName>>
|
certificateActions?: Array<CapabilitySchema<CertificateActionName>>
|
||||||
|
fileSystemActions?: Array<CapabilitySchema<FileSystemActionName>>
|
||||||
|
dataCatalogActions?: Array<CapabilitySchema<DataCatalogActionName>>
|
||||||
|
queryActions?: Array<CapabilitySchema<QueryActionName>>
|
||||||
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
|
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
|
||||||
}
|
}
|
||||||
filters: FieldSchema[]
|
filters: FieldSchema[]
|
||||||
|
|||||||
Generated
+209
-119
@@ -22,6 +22,12 @@ 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-athena':
|
||||||
|
specifier: ^3.1146.0
|
||||||
|
version: 3.1156.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
|
||||||
@@ -58,6 +64,9 @@ importers:
|
|||||||
'@aws-sdk/client-ecs':
|
'@aws-sdk/client-ecs':
|
||||||
specifier: ^3.1076.0
|
specifier: ^3.1076.0
|
||||||
version: 3.1145.0
|
version: 3.1145.0
|
||||||
|
'@aws-sdk/client-efs':
|
||||||
|
specifier: ^3.1146.0
|
||||||
|
version: 3.1146.0
|
||||||
'@aws-sdk/client-eks':
|
'@aws-sdk/client-eks':
|
||||||
specifier: ^3.1076.0
|
specifier: ^3.1076.0
|
||||||
version: 3.1076.0
|
version: 3.1076.0
|
||||||
@@ -70,6 +79,9 @@ importers:
|
|||||||
'@aws-sdk/client-eventbridge':
|
'@aws-sdk/client-eventbridge':
|
||||||
specifier: ^3.1076.0
|
specifier: ^3.1076.0
|
||||||
version: 3.1121.0
|
version: 3.1121.0
|
||||||
|
'@aws-sdk/client-glue':
|
||||||
|
specifier: ^3.1146.0
|
||||||
|
version: 3.1156.0
|
||||||
'@aws-sdk/client-iam':
|
'@aws-sdk/client-iam':
|
||||||
specifier: ^3.1076.0
|
specifier: ^3.1076.0
|
||||||
version: 3.1120.0
|
version: 3.1120.0
|
||||||
@@ -91,6 +103,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
|
||||||
@@ -317,6 +332,14 @@ 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-athena@3.1156.0':
|
||||||
|
resolution: {integrity: sha512-rqebnBvY6c9DuSKZz99dI0BS01HZl3iqXYTWTqbEbu02C1YCiYoho7tW7buAnNqIhZ1laB+EJh/JncO59pruJA==}
|
||||||
|
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'}
|
||||||
@@ -365,6 +388,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-r3Kfyv+ax47DcXUKgMV/n9xUhKL1KjDBo3HC6+4CL+lOWXXYXsEJIWpQrDZYVwX8nbxuD7TXVFfEeY/38dmD1Q==}
|
resolution: {integrity: sha512-r3Kfyv+ax47DcXUKgMV/n9xUhKL1KjDBo3HC6+4CL+lOWXXYXsEJIWpQrDZYVwX8nbxuD7TXVFfEeY/38dmD1Q==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/client-efs@3.1146.0':
|
||||||
|
resolution: {integrity: sha512-hu8iQM3KlhUn7zAS5iZEi0Vf6R/NaCPZXiQdqcVZYBwABnqUM4aTgdZmnhVM1I1+hpE/xWZ+HAfOs/aygGYifw==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/client-eks@3.1076.0':
|
'@aws-sdk/client-eks@3.1076.0':
|
||||||
resolution: {integrity: sha512-Hqr3hdwUgqWAQbdx6UDy81UBF+jdu3ws6RrykQ4YmBhe/XOKv57lt2lhkjgBuF9fiSw4UUl2XQteks9oxgm4XA==}
|
resolution: {integrity: sha512-Hqr3hdwUgqWAQbdx6UDy81UBF+jdu3ws6RrykQ4YmBhe/XOKv57lt2lhkjgBuF9fiSw4UUl2XQteks9oxgm4XA==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -381,6 +408,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-+zT6z91ZxHozsKGWf5wOJfxEebht3GmPBoXedlWoumHeAvzAueM1KwbWkzClLWjpi76HXuCIx7R3x4pvSVGqIw==}
|
resolution: {integrity: sha512-+zT6z91ZxHozsKGWf5wOJfxEebht3GmPBoXedlWoumHeAvzAueM1KwbWkzClLWjpi76HXuCIx7R3x4pvSVGqIw==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@aws-sdk/client-glue@3.1156.0':
|
||||||
|
resolution: {integrity: sha512-IkH03UtDnHWpbcdN11bWOr4XeS7F13veZ3/1ukRPznRwtrtXBVC3SivkQVJEeQ18stsKqgX3xMOK8UZH9imiiQ==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@aws-sdk/client-iam@3.1120.0':
|
'@aws-sdk/client-iam@3.1120.0':
|
||||||
resolution: {integrity: sha512-w1UNruAHPJ2l6+fNsW3wBYa725ALR1FRQe+rLheeziNK3EmGK6wxTm39dgKyehPmeSXxEQYCg5gcrYq3Ew3CzQ==}
|
resolution: {integrity: sha512-w1UNruAHPJ2l6+fNsW3wBYa725ALR1FRQe+rLheeziNK3EmGK6wxTm39dgKyehPmeSXxEQYCg5gcrYq3Ew3CzQ==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -409,6 +440,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'}
|
||||||
@@ -2891,15 +2926,15 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-crypto/util@5.2.0':
|
'@aws-crypto/util@5.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/util-utf8': 2.3.0
|
'@smithy/util-utf8': 2.3.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/checksums@3.1000.13':
|
'@aws-sdk/checksums@3.1000.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.1
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.6
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.35.0
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
@@ -2948,6 +2983,28 @@ snapshots:
|
|||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.17.2
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/client-athena@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-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
|
||||||
@@ -3088,6 +3145,17 @@ snapshots:
|
|||||||
'@smithy/types': 4.19.0
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/client-efs@3.1146.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-eks@3.1076.0':
|
'@aws-sdk/client-eks@3.1076.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-crypto/sha256-browser': 5.2.0
|
'@aws-crypto/sha256-browser': 5.2.0
|
||||||
@@ -3135,6 +3203,17 @@ snapshots:
|
|||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.17.2
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@aws-sdk/client-glue@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-iam@3.1120.0':
|
'@aws-sdk/client-iam@3.1120.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.977.9
|
'@aws-sdk/core': 3.977.9
|
||||||
@@ -3214,6 +3293,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
|
||||||
@@ -3417,34 +3507,34 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/credential-provider-env@3.972.54':
|
'@aws-sdk/credential-provider-env@3.972.54':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-env@3.972.62':
|
'@aws-sdk/credential-provider-env@3.972.62':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-env@3.972.69':
|
'@aws-sdk/credential-provider-env@3.972.69':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-env@3.972.70':
|
'@aws-sdk/credential-provider-env@3.972.70':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-env@3.972.71':
|
'@aws-sdk/credential-provider-env@3.972.71':
|
||||||
@@ -3473,42 +3563,42 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/credential-provider-http@3.972.56':
|
'@aws-sdk/credential-provider-http@3.972.56':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/fetch-http-handler': 5.7.2
|
'@smithy/fetch-http-handler': 5.8.0
|
||||||
'@smithy/node-http-handler': 4.11.3
|
'@smithy/node-http-handler': 4.12.1
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-http@3.972.64':
|
'@aws-sdk/credential-provider-http@3.972.64':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/fetch-http-handler': 5.7.2
|
'@smithy/fetch-http-handler': 5.8.0
|
||||||
'@smithy/node-http-handler': 4.11.3
|
'@smithy/node-http-handler': 4.12.1
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-http@3.972.71':
|
'@aws-sdk/credential-provider-http@3.972.71':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/fetch-http-handler': 5.7.2
|
'@smithy/fetch-http-handler': 5.8.0
|
||||||
'@smithy/node-http-handler': 4.11.3
|
'@smithy/node-http-handler': 4.12.1
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-http@3.972.72':
|
'@aws-sdk/credential-provider-http@3.972.72':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/fetch-http-handler': 5.7.2
|
'@smithy/fetch-http-handler': 5.8.0
|
||||||
'@smithy/node-http-handler': 4.11.3
|
'@smithy/node-http-handler': 4.12.1
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-http@3.972.73':
|
'@aws-sdk/credential-provider-http@3.972.73':
|
||||||
@@ -3543,7 +3633,7 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/credential-provider-ini@3.972.61':
|
'@aws-sdk/credential-provider-ini@3.972.61':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/credential-provider-env': 3.972.71
|
'@aws-sdk/credential-provider-env': 3.972.71
|
||||||
'@aws-sdk/credential-provider-http': 3.972.73
|
'@aws-sdk/credential-provider-http': 3.972.73
|
||||||
'@aws-sdk/credential-provider-login': 3.972.60
|
'@aws-sdk/credential-provider-login': 3.972.60
|
||||||
@@ -3551,15 +3641,15 @@ snapshots:
|
|||||||
'@aws-sdk/credential-provider-sso': 3.973.15
|
'@aws-sdk/credential-provider-sso': 3.973.15
|
||||||
'@aws-sdk/credential-provider-web-identity': 3.972.77
|
'@aws-sdk/credential-provider-web-identity': 3.972.77
|
||||||
'@aws-sdk/nested-clients': 3.997.28
|
'@aws-sdk/nested-clients': 3.997.28
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/credential-provider-imds': 4.5.0
|
'@smithy/credential-provider-imds': 4.5.0
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-ini@3.973.14':
|
'@aws-sdk/credential-provider-ini@3.973.14':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/credential-provider-env': 3.972.71
|
'@aws-sdk/credential-provider-env': 3.972.71
|
||||||
'@aws-sdk/credential-provider-http': 3.972.73
|
'@aws-sdk/credential-provider-http': 3.972.73
|
||||||
'@aws-sdk/credential-provider-login': 3.972.76
|
'@aws-sdk/credential-provider-login': 3.972.76
|
||||||
@@ -3567,15 +3657,15 @@ snapshots:
|
|||||||
'@aws-sdk/credential-provider-sso': 3.973.15
|
'@aws-sdk/credential-provider-sso': 3.973.15
|
||||||
'@aws-sdk/credential-provider-web-identity': 3.972.77
|
'@aws-sdk/credential-provider-web-identity': 3.972.77
|
||||||
'@aws-sdk/nested-clients': 3.997.43
|
'@aws-sdk/nested-clients': 3.997.43
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/credential-provider-imds': 4.5.0
|
'@smithy/credential-provider-imds': 4.5.0
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-ini@3.973.15':
|
'@aws-sdk/credential-provider-ini@3.973.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/credential-provider-env': 3.972.71
|
'@aws-sdk/credential-provider-env': 3.972.71
|
||||||
'@aws-sdk/credential-provider-http': 3.972.73
|
'@aws-sdk/credential-provider-http': 3.972.73
|
||||||
'@aws-sdk/credential-provider-login': 3.972.77
|
'@aws-sdk/credential-provider-login': 3.972.77
|
||||||
@@ -3583,10 +3673,10 @@ snapshots:
|
|||||||
'@aws-sdk/credential-provider-sso': 3.973.15
|
'@aws-sdk/credential-provider-sso': 3.973.15
|
||||||
'@aws-sdk/credential-provider-web-identity': 3.972.77
|
'@aws-sdk/credential-provider-web-identity': 3.972.77
|
||||||
'@aws-sdk/nested-clients': 3.997.44
|
'@aws-sdk/nested-clients': 3.997.44
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/credential-provider-imds': 4.5.0
|
'@smithy/credential-provider-imds': 4.5.0
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-ini@3.973.16':
|
'@aws-sdk/credential-provider-ini@3.973.16':
|
||||||
@@ -3639,7 +3729,7 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/credential-provider-ini@3.973.7':
|
'@aws-sdk/credential-provider-ini@3.973.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/credential-provider-env': 3.972.62
|
'@aws-sdk/credential-provider-env': 3.972.62
|
||||||
'@aws-sdk/credential-provider-http': 3.972.64
|
'@aws-sdk/credential-provider-http': 3.972.64
|
||||||
'@aws-sdk/credential-provider-login': 3.972.69
|
'@aws-sdk/credential-provider-login': 3.972.69
|
||||||
@@ -3647,10 +3737,10 @@ snapshots:
|
|||||||
'@aws-sdk/credential-provider-sso': 3.973.6
|
'@aws-sdk/credential-provider-sso': 3.973.6
|
||||||
'@aws-sdk/credential-provider-web-identity': 3.972.68
|
'@aws-sdk/credential-provider-web-identity': 3.972.68
|
||||||
'@aws-sdk/nested-clients': 3.997.36
|
'@aws-sdk/nested-clients': 3.997.36
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/credential-provider-imds': 4.4.14
|
'@smithy/credential-provider-imds': 4.4.14
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-login@3.972.60':
|
'@aws-sdk/credential-provider-login@3.972.60':
|
||||||
@@ -3830,34 +3920,34 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/credential-provider-process@3.972.54':
|
'@aws-sdk/credential-provider-process@3.972.54':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-process@3.972.62':
|
'@aws-sdk/credential-provider-process@3.972.62':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-process@3.972.69':
|
'@aws-sdk/credential-provider-process@3.972.69':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-process@3.972.70':
|
'@aws-sdk/credential-provider-process@3.972.70':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-process@3.972.71':
|
'@aws-sdk/credential-provider-process@3.972.71':
|
||||||
@@ -3886,32 +3976,32 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/credential-provider-sso@3.972.60':
|
'@aws-sdk/credential-provider-sso@3.972.60':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.28
|
'@aws-sdk/nested-clients': 3.997.28
|
||||||
'@aws-sdk/token-providers': 3.1080.0
|
'@aws-sdk/token-providers': 3.1080.0
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-sso@3.973.13':
|
'@aws-sdk/credential-provider-sso@3.973.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.43
|
'@aws-sdk/nested-clients': 3.997.43
|
||||||
'@aws-sdk/token-providers': 3.1111.0
|
'@aws-sdk/token-providers': 3.1111.0
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-sso@3.973.14':
|
'@aws-sdk/credential-provider-sso@3.973.14':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.44
|
'@aws-sdk/nested-clients': 3.997.44
|
||||||
'@aws-sdk/token-providers': 3.1116.0
|
'@aws-sdk/token-providers': 3.1116.0
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-sso@3.973.15':
|
'@aws-sdk/credential-provider-sso@3.973.15':
|
||||||
@@ -3946,48 +4036,48 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/credential-provider-sso@3.973.6':
|
'@aws-sdk/credential-provider-sso@3.973.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.36
|
'@aws-sdk/nested-clients': 3.997.36
|
||||||
'@aws-sdk/token-providers': 3.1096.0
|
'@aws-sdk/token-providers': 3.1096.0
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-web-identity@3.972.60':
|
'@aws-sdk/credential-provider-web-identity@3.972.60':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.28
|
'@aws-sdk/nested-clients': 3.997.28
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-web-identity@3.972.68':
|
'@aws-sdk/credential-provider-web-identity@3.972.68':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.36
|
'@aws-sdk/nested-clients': 3.997.36
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-web-identity@3.972.75':
|
'@aws-sdk/credential-provider-web-identity@3.972.75':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.43
|
'@aws-sdk/nested-clients': 3.997.43
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-web-identity@3.972.76':
|
'@aws-sdk/credential-provider-web-identity@3.972.76':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-sdk/core': 3.978.0
|
'@aws-sdk/core': 3.978.5
|
||||||
'@aws-sdk/nested-clients': 3.997.44
|
'@aws-sdk/nested-clients': 3.997.44
|
||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.975.0
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/credential-provider-web-identity@3.972.77':
|
'@aws-sdk/credential-provider-web-identity@3.972.77':
|
||||||
@@ -4298,17 +4388,17 @@ snapshots:
|
|||||||
|
|
||||||
'@aws-sdk/xml-builder@3.972.33':
|
'@aws-sdk/xml-builder@3.972.33':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/xml-builder@3.972.37':
|
'@aws-sdk/xml-builder@3.972.37':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/xml-builder@3.972.39':
|
'@aws-sdk/xml-builder@3.972.39':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@aws-sdk/xml-builder@3.972.40':
|
'@aws-sdk/xml-builder@3.972.40':
|
||||||
@@ -4791,14 +4881,14 @@ snapshots:
|
|||||||
|
|
||||||
'@smithy/credential-provider-imds@4.4.14':
|
'@smithy/credential-provider-imds@4.4.14':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@smithy/credential-provider-imds@4.4.6':
|
'@smithy/credential-provider-imds@4.4.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@smithy/credential-provider-imds@4.5.0':
|
'@smithy/credential-provider-imds@4.5.0':
|
||||||
@@ -4886,14 +4976,14 @@ snapshots:
|
|||||||
|
|
||||||
'@smithy/signature-v4@5.6.10':
|
'@smithy/signature-v4@5.6.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@smithy/signature-v4@5.6.2':
|
'@smithy/signature-v4@5.6.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.36.2
|
||||||
'@smithy/types': 4.17.2
|
'@smithy/types': 4.19.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@smithy/signature-v4@5.7.0':
|
'@smithy/signature-v4@5.7.0':
|
||||||
|
|||||||
Reference in New Issue
Block a user