Compare commits

..

7 Commits

Author SHA1 Message Date
Zakaria ca3e67a803 feat(api): add AppSync GraphQL APIs to the cloud explorer
CI / checks (pull_request) Successful in 28m38s
Create GraphQL APIs with API-key (the first key is made for you), IAM or
Cognito user pool authorization, then edit the schema (a starter schema is
offered, and a rejected one is explained), create and delete API keys, create
data sources for a DynamoDB table, a Lambda function or none, and attach
JavaScript resolvers to fields of the schema from a template for the data
source.

TheCloud's endpoint checks queries against the schema and the API key but does
not run resolvers yet, so the page says so and offers no query runner.
2026-10-25 18:21:46 -04:00
Zakaria 5eee0b7e9b feat(api): add Athena workgroups and a query editor to the cloud explorer
CI / checks (pull_request) Successful in 28m25s
Create and delete Athena workgroups (with a result folder, optionally
enforced), and run SQL in one: pick a database from the Glue Data Catalog,
preview a table, run with Ctrl+Enter, cancel a running query, read up to 1,000
rows and download the full result CSV from S3. Recent queries in the workgroup
can be reopened. TheCloud runs the SQL for real over the files a Glue table
points at. The Glue page links to the editor.
2026-10-25 18:10:33 -04:00
Zakaria 69d80c60a3 feat(api): add the Glue Data Catalog to the cloud explorer
CI / checks (pull_request) Successful in 28m15s
Create and delete Glue databases, and describe a folder of CSV, JSON-lines or
Parquet files in S3 as a table: its columns, and for CSV the delimiter and
whether the first line is a header. Tables are written with the storage
settings Athena expects, so TheCloud's Athena can query one as soon as it
exists. Glue jobs and crawlers are not offered: the runtime stores them but
never runs them.
2026-10-25 17:51:16 -04:00
Zakaria 26b635db0b feat(api): add EFS file systems to the cloud explorer
CI / checks (pull_request) Successful in 28m26s
Create file systems (with a mount target in each default subnet unless asked
not to), add and remove mount targets, create and delete access points, and
attach a file system to a Lambda function through an access point. A function
not yet in a VPC is put in the file system's mount target subnets with the
default security group, as Lambda requires. TheCloud's mounts are real, so two
functions on one access point share files.

Deleting a file system removes its access points and mount targets first, and
is refused while a function still mounts it.
2026-10-25 17:33:19 -04:00
Zakaria efaa2e6826 Merge pull request 'feat(api): add EventBridge Pipes to the cloud explorer' (#26) from feat/ec2-auto-scaling into main
CI / checks (push) Successful in 27m18s
Reviewed-on: #26
2026-10-25 17:22:16 +00:00
Zakaria 48114da772 Merge pull request 'feat(api): add ACM certificates to the cloud explorer' (#23) from feat/acm into main
CI / checks (push) Has been cancelled
Reviewed-on: #23
2026-10-25 17:21:28 +00:00
Zakaria 70cfd4bc96 feat(api): add EC2 Auto Scaling groups to the cloud explorer
CI / checks (pull_request) Successful in 26m54s
Create groups from a launch template (in the default subnets unless given),
change their minimum, desired and maximum capacity, replace or remove an
instance, and follow the launches and terminations TheCloud's reconciler makes.
Deleting a group terminates its instances. Scaling policies are not offered:
the runtime stores them but its alarms only drive ECS scaling.
2026-10-25 13:02:49 -04:00
43 changed files with 5919 additions and 120 deletions
+5
View File
@@ -58,6 +58,7 @@ cd packages/api && bun run scripts/service-matrix.ts
| Group | Service | AWS | Azure | GCP |
|---|---|---|---|---|
| Compute | Compute | Yes (list, inspect, create, delete) | Yes (list, inspect, create, delete) | No |
| Compute | EC2 Auto Scaling / Auto Scaling | Yes (list, create, delete, inspect) | No | No |
| Compute | EKS / AKS / GKE | Yes (list, inspect) | Yes (list, inspect) | Yes (list, create, inspect, delete) |
| Compute | Serverless | Yes (list, create, update, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) |
| Compute | Containers / Cloud Run | No | No | Yes (list, create, delete, inspect) |
@@ -69,6 +70,7 @@ cd packages/api && bun run scripts/service-matrix.ts
| 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 | 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 | 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 |
@@ -79,6 +81,7 @@ cd packages/api && bun run scripts/service-matrix.ts
| Networking | CloudFront / CDN | Yes (list, create, delete, inspect) | No | No |
| Integration | SQS / Messaging / Pub/Sub | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
| Integration | API Gateway | Yes (list, create, delete, inspect) | No | No |
| Integration | AppSync / GraphQL APIs | Yes (list, create, delete, inspect) | No | No |
| Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No |
| Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No |
| Integration | EventBridge Pipes / Pipes | Yes (list, create, delete, inspect) | No | No |
@@ -88,6 +91,8 @@ cd packages/api && bun run scripts/service-matrix.ts
| 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 | 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 | AppConfig / Configuration | Yes (list, create, delete, inspect) | No | No |
| Security | Identity | Yes (list, create, delete, inspect) | No | No |
+5
View File
@@ -14,6 +14,9 @@
"@aws-sdk/client-api-gateway": "^3.1076.0",
"@aws-sdk/client-apigatewayv2": "^3.1076.0",
"@aws-sdk/client-appconfig": "^3.1076.0",
"@aws-sdk/client-appsync": "^3.1146.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-cloudfront": "^3.1146.0",
"@aws-sdk/client-cloudwatch": "^3.1076.0",
@@ -26,10 +29,12 @@
"@aws-sdk/client-ec2": "^3.1076.0",
"@aws-sdk/client-ecr": "^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-elastic-load-balancing-v2": "^3.1096.0",
"@aws-sdk/client-elasticache": "^3.1146.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-kafka": "^3.1146.0",
"@aws-sdk/client-kafkaconnect": "^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,165 @@
import {describe, expect, test} from 'bun:test'
import {
type AppSyncClient,
CreateApiKeyCommand,
CreateDataSourceCommand,
CreateGraphqlApiCommand,
CreateResolverCommand,
DeleteResolverCommand,
GetGraphqlApiCommand,
GetIntrospectionSchemaCommand,
GetSchemaCreationStatusCommand,
ListApiKeysCommand,
ListDataSourcesCommand,
ListGraphqlApisCommand,
ListResolversCommand,
StartSchemaCreationCommand,
} from '@aws-sdk/client-appsync'
import {AwsGraphqlAdapter, objectTypes, schemaFailure, sdlFrom} from './AwsGraphqlAdapter'
import {ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
const sdl = `"""A note"""
type Note { id: ID! text: String }
# the entry points
type Query { note(id: ID!): Note notes(filter: NoteFilter, limit: Int = 10): [Note] }
type Mutation {
addNote(text: String!): Note
}
input NoteFilter { text: String }
schema { query: Query mutation: Mutation }`
const api = {
apiId: 'a1', name: 'notes-api', authenticationType: 'API_KEY', arn: 'arn:aws:appsync:eu-west-1:000000000000:apis/a1',
uris: {GRAPHQL: 'http://localhost:4566/v1/apis/a1/graphql', REALTIME: 'ws://localhost:4566/v1/apis/a1/graphql/realtime'},
}
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 GetGraphqlApiCommand) return {graphqlApi: api}
if (command instanceof CreateGraphqlApiCommand) return {graphqlApi: api}
if (command instanceof GetIntrospectionSchemaCommand) return {schema: new TextEncoder().encode(sdl)}
if (command instanceof GetSchemaCreationStatusCommand) return {status: 'SUCCESS'}
if (command instanceof ListApiKeysCommand) return {apiKeys: [{id: 'da2-key', description: 'web', expires: 1793570400}]}
if (command instanceof ListDataSourcesCommand) return {dataSources: [{name: 'notesTable', type: 'AMAZON_DYNAMODB', dynamodbConfig: {tableName: 'notes'}, serviceRoleArn: 'arn:aws:iam::000000000000:role/appsync'}]}
if (command instanceof ListResolversCommand) {
return command.input.typeName === 'Query' ? {resolvers: [{typeName: 'Query', fieldName: 'note', dataSourceName: 'notesTable', kind: 'UNIT', runtime: {name: 'APPSYNC_JS'}, code: 'export function request() {}'}]} : {resolvers: []}
}
return {}
},
}
return {sent, adapter: new AwsGraphqlAdapter(client as unknown as AppSyncClient, async () => {})}
}
describe('AwsGraphqlAdapter', () => {
test('reads the object types and fields a resolver can attach to', () => {
expect(objectTypes(sdl)).toEqual([
{name: 'Query', fields: ['note', 'notes']},
{name: 'Mutation', fields: ['addNote']},
{name: 'Note', fields: ['id', 'text']},
])
expect(objectTypes('type Query { a: String }\nextend type Query { b: Int }')).toEqual([{name: 'Query', fields: ['a', 'b']}])
})
test('reads the SDL whether it comes bare, as from AWS, or wrapped, as from the runtime', () => {
expect(sdlFrom('type Query { a: String }')).toBe('type Query { a: String }')
expect(sdlFrom(JSON.stringify({schema: {definition: 'type Query { a: String }'}}))).toBe('type Query { a: String }')
})
test('explains a rejected schema in the parser\'s words', () => {
const runtime = JSON.stringify({reason: 'CODE_ERROR', detail: {codeErrors: [{errorType: 'PARSER_ERROR', value: "Invalid syntax with offending token '<EOF>' at line 1 column 20"}]}})
expect(schemaFailure(runtime)).toBe("Invalid syntax with offending token '<EOF>' at line 1 column 20")
expect(schemaFailure('Failed to parse schema document')).toBe('Failed to parse schema document')
expect(schemaFailure(undefined)).toBe('AppSync rejected it')
})
test('lists APIs with their endpoint and authorization', async () => {
const {adapter} = adapterWith((command) => command instanceof ListGraphqlApisCommand ? {graphqlApis: [api]} : undefined)
const [listed] = await adapter.list()
expect([listed.id, listed.name, listed.metadata.authenticationLabel, listed.metadata.graphqlUrl]).toEqual(['a1', 'notes-api', 'API key', 'http://localhost:4566/v1/apis/a1/graphql'])
expect(await adapter.list({search: 'other'})).toEqual([])
})
test('describes the schema, keys, data sources and resolvers', async () => {
const {adapter, sent} = adapterWith()
const detail = await adapter.get('a1')
expect(detail?.metadata.definition).toBe(sdl)
expect(detail?.metadata.apiKeys).toEqual([{id: 'da2-key', description: 'web', expiresAt: '2026-11-01T22:00:00.000Z'}])
expect(detail?.metadata.dataSources).toEqual([{name: 'notesTable', type: 'AMAZON_DYNAMODB', description: null, tableName: 'notes', functionArn: null, serviceRoleArn: 'arn:aws:iam::000000000000:role/appsync'}])
expect(detail?.metadata.resolvers).toEqual([{typeName: 'Query', fieldName: 'note', dataSourceName: 'notesTable', kind: 'UNIT', runtime: 'APPSYNC_JS', code: 'export function request() {}'}])
expect((sent.filter((command) => command instanceof ListResolversCommand) as ListResolversCommand[]).map((command) => command.input.typeName)).toEqual(['Query', 'Mutation', 'Note'])
})
test('a new API-key API gets its first key, and Cognito needs a user pool', async () => {
const {adapter, sent} = adapterWith()
await adapter.create({values: {name: 'notes-api', authenticationType: 'API_KEY'}})
expect(sent.some((command) => command instanceof CreateApiKeyCommand)).toBe(true)
await adapter.create({values: {name: 'members', authenticationType: 'AMAZON_COGNITO_USER_POOLS', userPoolId: 'us-east-1_AbC123'}})
const cognito = (sent.filter((command) => command instanceof CreateGraphqlApiCommand) as CreateGraphqlApiCommand[])[1]
expect(cognito.input.userPoolConfig).toEqual({userPoolId: 'us-east-1_AbC123', awsRegion: 'us-east-1', defaultAction: 'ALLOW'})
await expect(adapter.create({values: {name: 'members', authenticationType: 'AMAZON_COGNITO_USER_POOLS'}})).rejects.toBeInstanceOf(ValidationError)
})
test('saving a schema waits for AppSync to compile it and reports a rejection', async () => {
let polls = 0
const {adapter, sent} = adapterWith((command) => command instanceof GetSchemaCreationStatusCommand ? {status: ++polls < 3 ? 'PROCESSING' : 'SUCCESS'} : undefined)
await adapter.updateGraphqlSchema('a1', 'type Query { a: String }')
expect(new TextDecoder().decode((sent.find((command) => command instanceof StartSchemaCreationCommand) as StartSchemaCreationCommand).input.definition)).toBe('type Query { a: String }')
expect(polls).toBeGreaterThanOrEqual(3)
const failing = adapterWith((command) => command instanceof GetSchemaCreationStatusCommand ? {status: 'FAILED', details: 'Syntax Error: Unexpected Name "typo"'} : undefined)
await expect(failing.adapter.updateGraphqlSchema('a1', 'typo Query')).rejects.toThrow('The schema was not saved: Syntax Error: Unexpected Name "typo"')
})
test('creates an API key that expires after the given days', async () => {
const {adapter, sent} = adapterWith()
const before = Math.floor(Date.now() / 1000)
await adapter.createGraphqlApiKey('a1', {description: 'mobile', expiresInDays: 30})
const create = sent.find((command) => command instanceof CreateApiKeyCommand) as CreateApiKeyCommand
expect(create.input.description).toBe('mobile')
expect((create.input.expires ?? 0) - before).toBeGreaterThanOrEqual(30 * 86_400)
await expect(adapter.createGraphqlApiKey('a1', {expiresInDays: 400})).rejects.toBeInstanceOf(ValidationError)
})
test('creates DynamoDB and Lambda data sources with a role, in the API region', async () => {
const {adapter, sent} = adapterWith()
const role = 'arn:aws:iam::000000000000:role/appsync'
await adapter.createGraphqlDataSource('a1', {name: 'notesTable', type: 'AMAZON_DYNAMODB', tableName: 'notes', serviceRoleArn: role})
await adapter.createGraphqlDataSource('a1', {name: 'notesFn', type: 'AWS_LAMBDA', functionArn: 'arn:aws:lambda:eu-west-1:000000000000:function:notes', serviceRoleArn: role})
await adapter.createGraphqlDataSource('a1', {name: 'local', type: 'NONE'})
const [table, fn, none] = (sent.filter((command) => command instanceof CreateDataSourceCommand) as CreateDataSourceCommand[]).map((command) => command.input)
expect(table).toEqual({apiId: 'a1', name: 'notesTable', type: 'AMAZON_DYNAMODB', serviceRoleArn: role, dynamodbConfig: {tableName: 'notes', awsRegion: 'eu-west-1'}})
expect(fn.lambdaConfig).toEqual({lambdaFunctionArn: 'arn:aws:lambda:eu-west-1:000000000000:function:notes'})
expect(none).toEqual({apiId: 'a1', name: 'local', type: 'NONE'})
await expect(adapter.createGraphqlDataSource('a1', {name: 'notesTable', type: 'AMAZON_DYNAMODB', tableName: 'notes'})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.createGraphqlDataSource('a1', {name: 'bad-name', type: 'NONE'})).rejects.toBeInstanceOf(ValidationError)
})
test('attaches a JavaScript resolver only to a field the schema has', async () => {
const {adapter, sent} = adapterWith()
const code = 'export function request(ctx) { return {} }\nexport function response(ctx) { return ctx.result }'
await adapter.createGraphqlResolver('a1', {typeName: 'Mutation', fieldName: 'addNote', dataSourceName: 'notesTable', code})
expect((sent.find((command) => command instanceof CreateResolverCommand) as CreateResolverCommand).input).toEqual({
apiId: 'a1', typeName: 'Mutation', fieldName: 'addNote', dataSourceName: 'notesTable', kind: 'UNIT', code, runtime: {name: 'APPSYNC_JS', runtimeVersion: '1.0.0'},
})
await expect(adapter.createGraphqlResolver('a1', {typeName: 'Query', fieldName: 'nope', dataSourceName: 'notesTable', code})).rejects.toThrow('Query has no field nope')
await expect(adapter.createGraphqlResolver('a1', {typeName: 'Ghost', fieldName: 'a', dataSourceName: 'notesTable', code})).rejects.toThrow('The schema has no type Ghost')
await expect(adapter.createGraphqlResolver('a1', {typeName: 'Query', fieldName: 'note', dataSourceName: 'notesTable', code: 'return 1'})).rejects.toBeInstanceOf(ValidationError)
})
test('detaches a resolver', async () => {
const {adapter, sent} = adapterWith()
await adapter.deleteGraphqlResolver('a1', 'Query', 'note')
expect((sent.find((command) => command instanceof DeleteResolverCommand) as DeleteResolverCommand).input).toEqual({apiId: 'a1', typeName: 'Query', fieldName: 'note'})
})
})
@@ -0,0 +1,405 @@
import {
type ApiKey,
type AppSyncClient,
CreateApiKeyCommand,
CreateDataSourceCommand,
CreateGraphqlApiCommand,
CreateResolverCommand,
type DataSource,
DeleteApiKeyCommand,
DeleteDataSourceCommand,
DeleteGraphqlApiCommand,
DeleteResolverCommand,
GetGraphqlApiCommand,
GetIntrospectionSchemaCommand,
GetSchemaCreationStatusCommand,
type GetSchemaCreationStatusCommandOutput,
type GraphqlApi,
ListApiKeysCommand,
ListDataSourcesCommand,
ListGraphqlApisCommand,
ListResolversCommand,
type Resolver,
StartSchemaCreationCommand,
} from '@aws-sdk/client-appsync'
import {NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors'
import {
awsGraphqlSchema,
GRAPHQL_API_KEY_MAX_DAYS,
GRAPHQL_API_NAME_PATTERN,
GRAPHQL_AUTH_TYPES,
GRAPHQL_DATA_SOURCE_NAME_PATTERN,
GRAPHQL_DATA_SOURCE_TYPES,
} from '../cloud-spi/graphqlSchema'
import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
GraphqlApiKeyInput,
GraphqlDataSourceInput,
GraphqlResolverInput,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
const AUTH_LABELS: Record<string, string> = {API_KEY: 'API key', AWS_IAM: 'IAM', AMAZON_COGNITO_USER_POOLS: 'Cognito user pool', OPENID_CONNECT: 'OpenID Connect', AWS_LAMBDA: 'Lambda'}
const DAY_SECONDS = 86_400
/** How long a schema save waits for AppSync to finish compiling it before reporting PROCESSING. */
const SCHEMA_WAIT_ATTEMPTS = 20
const SCHEMA_WAIT_MS = 250
const ROLE_ARN = /^arn:aws[a-z-]*:iam::\d{12}:role\/.+$/
const LAMBDA_ARN = /^arn:aws[a-z-]*:lambda:[a-z0-9-]+:\d{12}:function:.+$/
/**
* Talks to AWS AppSync, verified against Floci core (community, `latest`).
*
* Notes from probing the runtime:
* - APIs, schemas, API keys, data sources and resolvers are all stored, and the
* GraphQL endpoint checks queries against the schema and the caller's key.
* - Resolvers are never run, so every field of a query comes back null. The
* console says so instead of offering a query runner that looks broken.
* - GetIntrospectionSchema wraps the SDL in {"schema": {"definition": …}} where
* AWS returns the SDL itself; both are read.
* - ListTypes returns nothing, so the types a resolver can attach to come from
* the schema's own SDL.
*/
export class AwsGraphqlAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'graphql' as const
constructor(private readonly appsync: AppSyncClient, private readonly sleep: (ms: number) => Promise<void> = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {}
schema(): ServiceSchema {
return awsGraphqlSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const found: GraphqlApi[] = []
let next: string | undefined
do {
const res = await this.appsync.send(new ListGraphqlApisCommand({maxResults: 25, ...(next ? {nextToken: next} : {})}))
found.push(...(res.graphqlApis ?? []))
next = res.nextToken
} while (next)
const search = query.search?.trim().toLowerCase()
return found.map((api) => toResource(api)).filter((api) => !search
|| api.name.toLowerCase().includes(search)
|| api.id.toLowerCase().includes(search))
}
async get(id: string): Promise<CloudResource | null> {
let api: GraphqlApi | undefined
try {
api = (await this.appsync.send(new GetGraphqlApiCommand({apiId: id}))).graphqlApi
} catch (error) {
if (errorName(error) === 'NotFoundException') return null
throw error
}
if (!api) return null
const [definition, schemaStatus, apiKeys, dataSources] = await Promise.all([this.definition(id), this.schemaStatus(id), this.apiKeys(id), this.dataSources(id)])
const types = definition ? objectTypes(definition) : []
const resolvers = (await Promise.all(types.map((type) => this.resolvers(id, type.name)))).flat()
return toResource(api, {definition, schemaStatus, apiKeys, dataSources, resolvers, types})
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const values = input.values
const name = text(values.name)
if (!new RegExp(GRAPHQL_API_NAME_PATTERN).test(name)) throw new ValidationError('name must be up to 128 letters, numbers, spaces, dashes or underscores')
const authenticationType = text(values.authenticationType) || 'API_KEY'
if (!(GRAPHQL_AUTH_TYPES as readonly string[]).includes(authenticationType)) throw new ValidationError(`authenticationType must be one of ${GRAPHQL_AUTH_TYPES.join(', ')}`)
const userPoolId = text(values.userPoolId)
if (authenticationType === 'AMAZON_COGNITO_USER_POOLS' && !userPoolId) throw new ValidationError('userPoolId is required for Cognito user pool authorization')
const created = await this.appsync.send(new CreateGraphqlApiCommand({
name,
authenticationType: authenticationType as (typeof GRAPHQL_AUTH_TYPES)[number],
...(authenticationType === 'AMAZON_COGNITO_USER_POOLS' ? {userPoolConfig: {userPoolId, awsRegion: userPoolId.split('_')[0], defaultAction: 'ALLOW'}} : {}),
}))
const apiId = created.graphqlApi?.apiId
if (!apiId) throw new RuntimeError('AppSync did not return the new API id')
// The AppSync console gives a new API-key API its first key, valid for seven days.
if (authenticationType === 'API_KEY') await this.appsync.send(new CreateApiKeyCommand({apiId, expires: expiry(7)}))
return this.detail(apiId)
}
async delete(id: string): Promise<void> {
await this.appsync.send(new DeleteGraphqlApiCommand({apiId: id}))
}
async updateGraphqlSchema(apiId: string, definition: string): Promise<CloudResource> {
const sdl = typeof definition === 'string' ? definition.trim() : ''
if (!sdl) throw new ValidationError('Enter a schema to save')
await this.appsync.send(new StartSchemaCreationCommand({apiId, definition: new TextEncoder().encode(sdl)}))
let status: GetSchemaCreationStatusCommandOutput | null = null
for (let attempt = 0; attempt < SCHEMA_WAIT_ATTEMPTS; attempt++) {
status = await this.appsync.send(new GetSchemaCreationStatusCommand({apiId}))
if (status.status !== 'PROCESSING') break
await this.sleep(SCHEMA_WAIT_MS)
}
if (status?.status === 'FAILED') throw new ValidationError(`The schema was not saved: ${schemaFailure(status.details)}`)
return this.detail(apiId)
}
async createGraphqlApiKey(apiId: string, input: GraphqlApiKeyInput): Promise<CloudResource> {
const days = input.expiresInDays ?? 7
if (!Number.isInteger(days) || days < 1 || days > GRAPHQL_API_KEY_MAX_DAYS) throw new ValidationError(`expiresInDays must be a whole number from 1 to ${GRAPHQL_API_KEY_MAX_DAYS}`)
const description = text(input.description)
await this.appsync.send(new CreateApiKeyCommand({apiId, expires: expiry(days), ...(description ? {description} : {})}))
return this.detail(apiId)
}
async deleteGraphqlApiKey(apiId: string, keyId: string): Promise<CloudResource> {
await this.appsync.send(new DeleteApiKeyCommand({apiId, id: keyId}))
return this.detail(apiId)
}
async createGraphqlDataSource(apiId: string, input: GraphqlDataSourceInput): Promise<CloudResource> {
const name = text(input.name)
if (!new RegExp(GRAPHQL_DATA_SOURCE_NAME_PATTERN).test(name)) throw new ValidationError('name must start with a letter or underscore and use only letters, numbers and underscores')
const type = text(input.type)
if (!(GRAPHQL_DATA_SOURCE_TYPES as readonly string[]).includes(type)) throw new ValidationError(`type must be one of ${GRAPHQL_DATA_SOURCE_TYPES.join(', ')}`)
const description = text(input.description)
const serviceRoleArn = text(input.serviceRoleArn)
if (type !== 'NONE' && !ROLE_ARN.test(serviceRoleArn)) throw new ValidationError('serviceRoleArn must be the IAM role AppSync uses to reach the table or function')
const tableName = text(input.tableName)
if (type === 'AMAZON_DYNAMODB' && !tableName) throw new ValidationError('tableName is required for a DynamoDB data source')
const functionArn = text(input.functionArn)
if (type === 'AWS_LAMBDA' && !LAMBDA_ARN.test(functionArn)) throw new ValidationError('functionArn must be a Lambda function ARN')
const region = await this.region(apiId)
await this.appsync.send(new CreateDataSourceCommand({
apiId,
name,
type: type as (typeof GRAPHQL_DATA_SOURCE_TYPES)[number],
...(description ? {description} : {}),
...(type !== 'NONE' ? {serviceRoleArn} : {}),
...(type === 'AMAZON_DYNAMODB' ? {dynamodbConfig: {tableName, awsRegion: region}} : {}),
...(type === 'AWS_LAMBDA' ? {lambdaConfig: {lambdaFunctionArn: functionArn}} : {}),
}))
return this.detail(apiId)
}
async deleteGraphqlDataSource(apiId: string, name: string): Promise<CloudResource> {
await this.appsync.send(new DeleteDataSourceCommand({apiId, name}))
return this.detail(apiId)
}
async createGraphqlResolver(apiId: string, input: GraphqlResolverInput): Promise<CloudResource> {
const typeName = text(input.typeName)
const fieldName = text(input.fieldName)
const dataSourceName = text(input.dataSourceName)
const code = typeof input.code === 'string' ? input.code : ''
if (!dataSourceName) throw new ValidationError('dataSourceName is required')
if (!/export\s+function\s+request\b/.test(code) || !/export\s+function\s+response\b/.test(code)) {
throw new ValidationError('code must export a request(ctx) and a response(ctx) function')
}
// AppSync refuses a resolver for a field the schema does not have; the runtime does not, so check here.
const definition = await this.definition(apiId)
const type = definition ? objectTypes(definition).find((candidate) => candidate.name === typeName) : undefined
if (!type) throw new ValidationError(`The schema has no type ${typeName}`)
if (!type.fields.includes(fieldName)) throw new ValidationError(`${typeName} has no field ${fieldName}`)
await this.appsync.send(new CreateResolverCommand({
apiId, typeName, fieldName, dataSourceName, kind: 'UNIT', code, runtime: {name: 'APPSYNC_JS', runtimeVersion: '1.0.0'},
}))
return this.detail(apiId)
}
async deleteGraphqlResolver(apiId: string, typeName: string, fieldName: string): Promise<CloudResource> {
await this.appsync.send(new DeleteResolverCommand({apiId, typeName, fieldName}))
return this.detail(apiId)
}
private async detail(apiId: string): Promise<CloudResource> {
const detail = await this.get(apiId)
if (!detail) throw new NotFoundError(`GraphQL API ${apiId} not found`)
return detail
}
private async region(apiId: string): Promise<string> {
const api = (await this.appsync.send(new GetGraphqlApiCommand({apiId}))).graphqlApi
return api?.arn?.split(':')[3] || 'us-east-1'
}
private async definition(apiId: string): Promise<string | null> {
try {
const res = await this.appsync.send(new GetIntrospectionSchemaCommand({apiId, format: 'SDL'}))
return res.schema ? sdlFrom(new TextDecoder().decode(res.schema)) : null
} catch (error) {
if (errorName(error) === 'NotFoundException') return null
throw error
}
}
private async schemaStatus(apiId: string): Promise<{status: string; details: string | null} | null> {
try {
const res = await this.appsync.send(new GetSchemaCreationStatusCommand({apiId}))
return res.status ? {status: res.status, details: res.details ?? null} : null
} catch (error) {
if (errorName(error) === 'NotFoundException') return null
throw error
}
}
private async apiKeys(apiId: string): Promise<ApiKey[]> {
const found: ApiKey[] = []
let next: string | undefined
do {
const res = await this.appsync.send(new ListApiKeysCommand({apiId, maxResults: 25, ...(next ? {nextToken: next} : {})}))
found.push(...(res.apiKeys ?? []))
next = res.nextToken
} while (next)
return found
}
private async dataSources(apiId: string): Promise<DataSource[]> {
const found: DataSource[] = []
let next: string | undefined
do {
const res = await this.appsync.send(new ListDataSourcesCommand({apiId, maxResults: 25, ...(next ? {nextToken: next} : {})}))
found.push(...(res.dataSources ?? []))
next = res.nextToken
} while (next)
return found.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
}
private async resolvers(apiId: string, typeName: string): Promise<Resolver[]> {
const found: Resolver[] = []
let next: string | undefined
do {
const res = await this.appsync.send(new ListResolversCommand({apiId, typeName, maxResults: 25, ...(next ? {nextToken: next} : {})}))
found.push(...(res.resolvers ?? []))
next = res.nextToken
} while (next)
return found
}
}
type ApiDetail = {
definition: string | null
schemaStatus: {status: string; details: string | null} | null
apiKeys: ApiKey[]
dataSources: DataSource[]
resolvers: Resolver[]
types: Array<{name: string; fields: string[]}>
}
function toResource(api: GraphqlApi, detail?: ApiDetail): CloudResource {
const id = api.apiId ?? ''
const authenticationType = api.authenticationType ?? null
return {
id,
name: api.name ?? id,
cloud: 'aws',
service: 'graphql',
type: 'graphqlApi',
region: api.arn?.split(':')[3] || null,
createdAt: null,
metadata: {
apiId: id,
arn: api.arn ?? null,
authenticationType,
authenticationLabel: authenticationType ? AUTH_LABELS[authenticationType] ?? authenticationType : null,
userPoolId: api.userPoolConfig?.userPoolId ?? null,
graphqlUrl: api.uris?.GRAPHQL ?? null,
realtimeUrl: api.uris?.REALTIME ?? null,
...(detail ? {
definition: detail.definition,
schemaStatus: detail.schemaStatus,
types: detail.types,
apiKeys: detail.apiKeys.map((key) => ({
id: key.id ?? '',
description: key.description || null,
expiresAt: key.expires ? new Date(key.expires * 1000).toISOString() : null,
})),
dataSources: detail.dataSources.map((source) => ({
name: source.name ?? '',
type: source.type ?? null,
description: source.description || null,
tableName: source.dynamodbConfig?.tableName ?? null,
functionArn: source.lambdaConfig?.lambdaFunctionArn ?? null,
serviceRoleArn: source.serviceRoleArn ?? null,
})),
resolvers: detail.resolvers.map((resolver) => ({
typeName: resolver.typeName ?? '',
fieldName: resolver.fieldName ?? '',
dataSourceName: resolver.dataSourceName ?? null,
kind: resolver.kind ?? null,
runtime: resolver.runtime?.name ?? (resolver.requestMappingTemplate ? 'VTL' : null),
code: resolver.code ?? null,
})),
} : {}),
},
}
}
/**
* Why a schema was rejected. AWS sends a sentence; the runtime sends JSON whose
* codeErrors carry the parser's messages, which are what a person needs.
*/
export function schemaFailure(details: string | undefined): string {
if (!details) return 'AppSync rejected it'
try {
const parsed = JSON.parse(details) as {detail?: {codeErrors?: Array<{value?: unknown}>}}
const messages = (parsed.detail?.codeErrors ?? []).map((error) => error.value).filter((value): value is string => typeof value === 'string' && value !== '')
return messages.length > 0 ? messages.join('; ') : details
} catch {
return details
}
}
/** AWS returns the SDL itself; the runtime wraps it as {"schema": {"definition": …}}. */
export function sdlFrom(body: string): string {
const trimmed = body.trim()
if (!trimmed.startsWith('{"')) return body
try {
const parsed = JSON.parse(trimmed) as {schema?: {definition?: unknown}}
return typeof parsed.schema?.definition === 'string' ? parsed.schema.definition : body
} catch {
return body
}
}
/**
* The object types of an SDL schema and their field names: what a resolver can
* attach to. A small reader, not a GraphQL parser: it drops comments, strings
* and arguments, then reads `type Name { field: … }` blocks.
*/
export function objectTypes(sdl: string): Array<{name: string; fields: string[]}> {
const stripped = sdl
.replace(/"""[\s\S]*?"""/g, '')
.replace(/"(?:[^"\\\n]|\\.)*"/g, '')
.replace(/#[^\n]*/g, '')
const types: Array<{name: string; fields: string[]}> = []
for (const match of stripped.matchAll(/(?:^|\s)(?:extend\s+)?type\s+([_A-Za-z][_0-9A-Za-z]*)[^{]*\{([^}]*)\}/g)) {
let body = match[2]
// Arguments may nest one level of input object literals; drop them before reading fields.
for (let previous = ''; previous !== body;) { previous = body; body = body.replace(/\([^()]*\)/g, '') }
const fields = [...body.matchAll(/([_A-Za-z][_0-9A-Za-z]*)\s*:/g)].map((field) => field[1])
const existing = types.find((type) => type.name === match[1])
if (existing) existing.fields.push(...fields.filter((field) => !existing.fields.includes(field)))
else types.push({name: match[1], fields})
}
const rootOrder = ['Query', 'Mutation', 'Subscription']
return types.sort((a, b) => {
const rank = (name: string) => rootOrder.includes(name) ? rootOrder.indexOf(name) : rootOrder.length
return rank(a.name) - rank(b.name) || a.name.localeCompare(b.name)
})
}
function expiry(days: number): number {
return Math.floor(Date.now() / 1000) + days * DAY_SECONDS
}
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
}
+15
View File
@@ -34,8 +34,13 @@ import { LightsailClient } from "@aws-sdk/client-lightsail";
import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider";
import { Route53Client } from "@aws-sdk/client-route-53";
import { CloudFrontClient } from "@aws-sdk/client-cloudfront";
import { AutoScalingClient } from "@aws-sdk/client-auto-scaling";
import { ACMClient } from "@aws-sdk/client-acm";
import { PipesClient } from "@aws-sdk/client-pipes";
import { EFSClient } from "@aws-sdk/client-efs";
import { GlueClient } from "@aws-sdk/client-glue";
import { AthenaClient } from "@aws-sdk/client-athena";
import { AppSyncClient } from "@aws-sdk/client-appsync";
const endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1";
@@ -101,8 +106,13 @@ export type AwsClients = {
cognito: CognitoIdentityProviderClient;
route53: Route53Client;
cloudfront: CloudFrontClient;
autoscaling: AutoScalingClient;
acm: ACMClient;
pipes: PipesClient;
efs: EFSClient;
glue: GlueClient;
athena: AthenaClient;
appsync: AppSyncClient;
};
export type AwsClientName = keyof AwsClients;
@@ -152,8 +162,13 @@ function buildClients(accountId: string): AwsClients {
cognito: new CognitoIdentityProviderClient(base),
route53: new Route53Client(base),
cloudfront: new CloudFrontClient(base),
autoscaling: new AutoScalingClient(base),
acm: new ACMClient(base),
pipes: new PipesClient(base),
efs: new EFSClient(base),
glue: new GlueClient(base),
athena: new AthenaClient(base),
appsync: new AppSyncClient(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,83 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
/** CreateGraphqlApi's name characters; the console keeps names to a readable length. */
export const GRAPHQL_API_NAME_PATTERN = '^[A-Za-z0-9_\\- ]{1,128}$'
/** CreateDataSource's name rule: a GraphQL-style identifier. */
export const GRAPHQL_DATA_SOURCE_NAME_PATTERN = '^[_A-Za-z][_0-9A-Za-z]{0,64}$'
export const GRAPHQL_AUTH_TYPES = ['API_KEY', 'AWS_IAM', 'AMAZON_COGNITO_USER_POOLS'] as const
export const GRAPHQL_DATA_SOURCE_TYPES = ['NONE', 'AMAZON_DYNAMODB', 'AWS_LAMBDA'] as const
/** AppSync's bounds on an API key's lifetime. */
export const GRAPHQL_API_KEY_MAX_DAYS = 365
const apiColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'id', label: 'API ID', path: 'metadata.apiId'},
{name: 'auth', label: 'Authorization', path: 'metadata.authenticationLabel'},
{name: 'endpoint', label: 'GraphQL endpoint', path: 'metadata.graphqlUrl', emptyText: '—'},
]
const apiFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
export function awsGraphqlSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'graphql',
displayName: 'AppSync GraphQL APIs',
fields: [
{
name: 'name',
label: 'API name',
type: 'text',
required: true,
validation: {pattern: GRAPHQL_API_NAME_PATTERN, message: 'Use up to 128 letters, numbers, spaces, dashes and underscores'},
},
{
name: 'authenticationType',
label: 'Authorization',
type: 'select',
required: false,
defaultValue: 'API_KEY',
description: 'How clients prove who they are. With an API key, AppSync creates the first key for you.',
options: [
{label: 'API key', value: 'API_KEY'},
{label: 'IAM (signed requests)', value: 'AWS_IAM'},
{label: 'Cognito user pool', value: 'AMAZON_COGNITO_USER_POOLS'},
],
},
{
name: 'userPoolId',
label: 'User pool ID',
type: 'text',
required: false,
requiredWhen: {field: 'authenticationType', equals: 'AMAZON_COGNITO_USER_POOLS'},
visibleWhen: {field: 'authenticationType', values: ['AMAZON_COGNITO_USER_POOLS']},
description: 'The Cognito user pool whose ID tokens the API accepts, such as us-east-1_AbC123.',
validation: {pattern: '^[\\w-]+_[0-9a-zA-Z]+$', message: 'Use a user pool ID such as us-east-1_AbC123'},
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List GraphQL APIs', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create GraphQL API', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete GraphQL API', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect GraphQL API', enabled: true, status: 'available', runtimeRequired: true},
],
graphqlActions: [
{name: 'updateSchema', label: 'Save schema', enabled: true, status: 'available', runtimeRequired: true},
{name: 'createApiKey', label: 'Create API key', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteApiKey', label: 'Delete API key', enabled: true, status: 'available', runtimeRequired: true},
{name: 'createDataSource', label: 'Create data source', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteDataSource', label: 'Delete data source', enabled: true, status: 'available', runtimeRequired: true},
{name: 'createResolver', label: 'Attach resolver', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteResolver', label: 'Detach resolver', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: apiFilters,
columns: apiColumns,
}
}
export function graphqlSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsGraphqlSchema() : null
}
+80
View File
@@ -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,
},
serverless: {displayName: 'Serverless', iconKey: 'serverless', group: 'Compute', order: 30},
autoscaling: {
displayName: 'Auto Scaling',
displayNameByCloud: {aws: 'EC2 Auto Scaling'},
iconKey: 'autoscaling',
group: 'Compute',
order: 15,
},
containers: {
displayName: 'Containers',
displayNameByCloud: {gcp: 'Cloud Run'},
@@ -125,6 +132,13 @@ export const SERVICE_CATALOG = {
group: 'Storage',
order: 20,
},
filesystems: {
displayName: 'File Systems',
displayNameByCloud: {aws: 'EFS'},
iconKey: 'filesystems',
group: 'Storage',
order: 30,
},
database: {displayName: 'Database', iconKey: 'database', group: 'Databases', order: 10},
nosql: {
displayName: 'NoSQL',
@@ -208,8 +222,29 @@ export const SERVICE_CATALOG = {
group: 'Analytics',
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},
apigateway: {displayName: 'API Gateway', iconKey: 'apigateway', group: 'Integration', order: 10},
graphql: {
displayName: 'GraphQL APIs',
displayNameByCloud: {aws: 'AppSync'},
iconKey: 'graphql',
group: 'Integration',
order: 12,
},
email: {
displayName: 'Email',
displayNameByCloud: {aws: 'SES Mailbox'},
+140
View File
@@ -186,7 +186,12 @@ export type UserPoolActionName =
| 'describeClient'
| 'updateClientOAuth'
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
export type CertificateActionName = 'download' | 'export' | 'revoke'
export type GraphqlActionName = 'updateSchema' | 'createApiKey' | 'deleteApiKey' | 'createDataSource' | 'deleteDataSource' | 'createResolver' | 'deleteResolver'
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 HttpApiActionName =
| 'describe'
@@ -246,7 +251,12 @@ export interface ServiceSchema {
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
distributionActions?: CapabilitySchema<DistributionActionName>[]
autoScalingActions?: CapabilitySchema<AutoScalingActionName>[]
certificateActions?: CapabilitySchema<CertificateActionName>[]
fileSystemActions?: CapabilitySchema<FileSystemActionName>[]
dataCatalogActions?: CapabilitySchema<DataCatalogActionName>[]
queryActions?: CapabilitySchema<QueryActionName>[]
graphqlActions?: CapabilitySchema<GraphqlActionName>[]
monitoringActions?: CapabilitySchema<MonitoringActionName>[]
/** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */
httpApiActions?: CapabilitySchema<HttpApiActionName>[]
@@ -537,6 +547,110 @@ export type CertificateRevocationReason =
| 'CESSATION_OF_OPERATION'
| 'PRIVILEGE_WITHDRAWN'
/** Any of the three may be left out to keep its current value. */
export interface AutoScalingCapacityInput {
minSize?: number
desiredCapacity?: number
maxSize?: number
}
export interface 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 GraphqlApiKeyInput {
description?: string
/** Days until the key expires: 1 to 365, 7 when left out, as in the AppSync console. */
expiresInDays?: number
}
export interface GraphqlDataSourceInput {
name: string
description?: string
type: 'NONE' | 'AMAZON_DYNAMODB' | 'AWS_LAMBDA'
/** AMAZON_DYNAMODB: the table the resolvers read and write. */
tableName?: string
/** AWS_LAMBDA: the function the resolvers invoke. */
functionArn?: string
/** The role AppSync assumes to reach the table or function; AWS requires one for both. */
serviceRoleArn?: string
}
export interface GraphqlResolverInput {
typeName: string
fieldName: string
dataSourceName: string
/** An APPSYNC_JS resolver: request() and response() exported from one module. */
code: 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 {
id: string
status: string
@@ -1204,7 +1318,33 @@ export interface CloudServiceAdapter {
setAlarmState?(alarmName: string, state: AlarmStateValue, reason: string): Promise<void>
setAlarmActionsEnabled?(alarmName: string, enabled: boolean): Promise<void>
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>
updateGraphqlSchema?(apiId: string, definition: string): Promise<CloudResource>
createGraphqlApiKey?(apiId: string, input: GraphqlApiKeyInput): Promise<CloudResource>
deleteGraphqlApiKey?(apiId: string, keyId: string): Promise<CloudResource>
createGraphqlDataSource?(apiId: string, input: GraphqlDataSourceInput): Promise<CloudResource>
deleteGraphqlDataSource?(apiId: string, name: string): Promise<CloudResource>
createGraphqlResolver?(apiId: string, input: GraphqlResolverInput): Promise<CloudResource>
deleteGraphqlResolver?(apiId: string, typeName: string, fieldName: string): Promise<CloudResource>
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>
revokeCertificate?(certificateId: string, reason: CertificateRevocationReason): Promise<CloudResource>
updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings>
+88 -1
View File
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
import {createCloudAdapterRegistry} from './cloudProxy'
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
import {isServiceType} from './cloud-spi/serviceCatalog'
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, CertificateActionName, DatabaseActionName, DistributionActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
import type {AutoScalingActionName, CloudProvider, CloudServiceAdapter, CloudServiceType, CertificateActionName, DatabaseActionName, DataCatalogActionName, DistributionActionName, FileSystemActionName, GraphqlActionName, HttpApiActionName, QueryActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
/**
* Guards the schema-to-implementation contract for every registered adapter.
@@ -81,6 +81,43 @@ const METHOD_FOR_CERTIFICATE_ACTION: Record<CertificateActionName, keyof CloudSe
revoke: 'revokeCertificate',
}
const METHOD_FOR_GRAPHQL_ACTION: Record<GraphqlActionName, keyof CloudServiceAdapter> = {
updateSchema: 'updateGraphqlSchema',
createApiKey: 'createGraphqlApiKey',
deleteApiKey: 'deleteGraphqlApiKey',
createDataSource: 'createGraphqlDataSource',
deleteDataSource: 'deleteGraphqlDataSource',
createResolver: 'createGraphqlResolver',
deleteResolver: 'deleteGraphqlResolver',
}
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> = {
updateUser: 'updateUserPoolUser',
setUserGroups: 'setUserPoolUserGroups',
@@ -203,6 +240,51 @@ describe('registered adapters honour their schema', () => {
).toBe('function')
}
const autoScalingClaims = (adapter.schema().capabilities?.autoScalingActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of autoScalingClaims) {
const method = METHOD_FOR_AUTO_SCALING_ACTION[capability.name]
expect(
typeof adapter[method],
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
).toBe('function')
}
const graphqlClaims = (adapter.schema().capabilities?.graphqlActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of graphqlClaims) {
const method = METHOD_FOR_GRAPHQL_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')
for (const capability of distributionClaims) {
const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name]
@@ -225,6 +307,11 @@ describe('registered adapters honour their schema', () => {
...(adapter.schema().capabilities?.kubernetesActions ?? []),
...(adapter.schema().capabilities?.userPoolActions ?? []),
...(adapter.schema().capabilities?.distributionActions ?? []),
...(adapter.schema().capabilities?.autoScalingActions ?? []),
...(adapter.schema().capabilities?.fileSystemActions ?? []),
...(adapter.schema().capabilities?.dataCatalogActions ?? []),
...(adapter.schema().capabilities?.queryActions ?? []),
...(adapter.schema().capabilities?.graphqlActions ?? []),
...(adapter.schema().capabilities?.certificateActions ?? []),
...(adapter.schema().capabilities?.monitoringActions ?? []),
...(adapter.schema().capabilities?.httpApiActions ?? []),
+10
View File
@@ -50,8 +50,13 @@ import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter'
import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter'
import {AwsAutoScalingAdapter} from './adapter-aws/AwsAutoScalingAdapter'
import {AwsCertificatesAdapter} from './adapter-aws/AwsCertificatesAdapter'
import {AwsPipesAdapter} from './adapter-aws/AwsPipesAdapter'
import {AwsFileSystemsAdapter} from './adapter-aws/AwsFileSystemsAdapter'
import {AwsDataCatalogAdapter} from './adapter-aws/AwsDataCatalogAdapter'
import {AwsQueryAdapter} from './adapter-aws/AwsQueryAdapter'
import {AwsGraphqlAdapter} from './adapter-aws/AwsGraphqlAdapter'
import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2'
@@ -105,8 +110,13 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsUserPoolsAdapter(clients.cognito),
new AwsDnsAdapter(clients.route53),
new AwsCdnAdapter(clients.cloudfront, clients.s3),
new AwsAutoScalingAdapter(clients.autoscaling, clients.ec2),
new AwsCertificatesAdapter(clients.acm, resolveAccountId(accountId)),
new AwsPipesAdapter(clients.pipes),
new AwsFileSystemsAdapter(clients.efs, clients.lambda, clients.ec2),
new AwsDataCatalogAdapter(clients.glue),
new AwsQueryAdapter(clients.athena),
new AwsGraphqlAdapter(clients.appsync),
new AwsMonitoringAdapter(clients.cloudwatch),
new AzureStorageAdapter(),
new AzureServiceBusAdapter(),
+124
View File
@@ -18,6 +18,11 @@ import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-s
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
import {awsCdnSchema} from '../cloud-spi/cdnSchema'
import {awsAutoScalingSchema} from '../cloud-spi/autoScalingSchema'
import {awsFileSystemsSchema} from '../cloud-spi/fileSystemsSchema'
import {awsDataCatalogSchema} from '../cloud-spi/dataCatalogSchema'
import {awsQuerySchema} from '../cloud-spi/querySchema'
import {awsGraphqlSchema} from '../cloud-spi/graphqlSchema'
import {awsCertificatesSchema} from '../cloud-spi/certificatesSchema'
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
@@ -852,6 +857,125 @@ describe('cloud schema routes', () => {
expect(calls).toEqual([['pem', 'c1'], ['export', 'c1', 'secret'], ['revoke', 'c1', 'UNSPECIFIED']])
})
test('changes capacity and terminates an instance through the Auto Scaling adapter', async () => {
const calls: unknown[] = []
const app = appWithRoutes([mockAdapter('aws', {
service: 'autoscaling',
schema: awsAutoScalingSchema,
setAutoScalingCapacity: async (id, input) => {
calls.push(['capacity', id, input])
return {id, name: id, cloud: 'aws', service: 'autoscaling', type: 'autoScalingGroup', region: null, createdAt: null, metadata: {desiredCapacity: 3}}
},
terminateAutoScalingInstance: async (id, instanceId, replace) => {
calls.push(['terminate', id, instanceId, replace])
},
})])
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
const base = '/api/clouds/aws/services/autoscaling/resources/web-asg'
const updated = await app.request(`${base}/capacity`, json('PUT', {desiredCapacity: 3}))
expect((await updated.json()).metadata.desiredCapacity).toBe(3)
expect((await app.request(`${base}/instances/i-abc/terminate`, json('POST', {replace: true}))).status).toBe(200)
expect(calls).toEqual([['capacity', 'web-asg', {desiredCapacity: 3}], ['terminate', 'web-asg', 'i-abc', true]])
})
test('manages the schema, keys, data sources and resolvers through the AppSync adapter', async () => {
const calls: unknown[] = []
const api = {id: 'a1', name: 'notes', cloud: 'aws' as const, service: 'graphql' as const, type: 'graphqlApi', region: null, createdAt: null, metadata: {}}
const app = appWithRoutes([mockAdapter('aws', {
service: 'graphql',
schema: awsGraphqlSchema,
updateGraphqlSchema: async (id, definition) => { calls.push(['schema', id, definition]); return api },
createGraphqlApiKey: async (id, input) => { calls.push(['key', id, input.expiresInDays]); return api },
deleteGraphqlApiKey: async (id, keyId) => { calls.push(['delete-key', id, keyId]); return api },
createGraphqlDataSource: async (id, input) => { calls.push(['source', id, input.name, input.type]); return api },
deleteGraphqlDataSource: async (id, name) => { calls.push(['delete-source', id, name]); return api },
createGraphqlResolver: async (id, input) => { calls.push(['resolver', id, input.typeName, input.fieldName]); return api },
deleteGraphqlResolver: async (id, typeName, fieldName) => { calls.push(['delete-resolver', id, typeName, fieldName]); return api },
})])
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
const base = '/api/clouds/aws/services/graphql/resources/a1'
expect((await app.request(`${base}/schema`, json('PUT', {definition: 'type Query { a: String }'}))).status).toBe(200)
expect((await app.request(`${base}/api-keys`, json('POST', {expiresInDays: 30}))).status).toBe(201)
expect((await app.request(`${base}/api-keys/da2-x`, {method: 'DELETE'})).status).toBe(200)
expect((await app.request(`${base}/data-sources`, json('POST', {name: 'none', type: 'NONE'}))).status).toBe(201)
expect((await app.request(`${base}/data-sources/none`, {method: 'DELETE'})).status).toBe(200)
expect((await app.request(`${base}/resolvers`, json('POST', {typeName: 'Query', fieldName: 'a', dataSourceName: 'none', code: ''}))).status).toBe(201)
expect((await app.request(`${base}/resolvers/Query/a`, {method: 'DELETE'})).status).toBe(200)
expect(calls).toEqual([
['schema', 'a1', 'type Query { a: String }'], ['key', 'a1', 30], ['delete-key', 'a1', 'da2-x'],
['source', 'a1', 'none', 'NONE'], ['delete-source', 'a1', 'none'], ['resolver', 'a1', 'Query', 'a'], ['delete-resolver', 'a1', 'Query', 'a'],
])
})
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 () => {
let cleared = false
const app = appWithRoutes([mockAdapter('aws', {
+175
View File
@@ -2,6 +2,14 @@ import {Hono} from 'hono'
import type {Context} from 'hono'
import type {
AlarmStateValue,
AutoScalingCapacityInput,
AccessPointInput,
CatalogTableInput,
QueryInput,
GraphqlApiKeyInput,
GraphqlDataSourceInput,
GraphqlResolverInput,
FileSystemAttachmentInput,
CertificateRevocationReason,
CloudProvider,
CloudServiceType,
@@ -553,6 +561,173 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
})
})
app.put('/:cloud/services/graphql/resources/:id/schema', 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<{definition?: string}>()
return c.json(await svc(c).updateGraphqlSchema(cloud, c.req.param('id'), body.definition ?? ''))
})
})
app.post('/:cloud/services/graphql/resources/:id/api-keys', 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).createGraphqlApiKey(cloud, c.req.param('id'), await c.req.json<GraphqlApiKeyInput>()), 201))
})
app.delete('/:cloud/services/graphql/resources/:id/api-keys/:keyId', 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).deleteGraphqlApiKey(cloud, c.req.param('id'), c.req.param('keyId'))))
})
app.post('/:cloud/services/graphql/resources/:id/data-sources', 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).createGraphqlDataSource(cloud, c.req.param('id'), await c.req.json<GraphqlDataSourceInput>()), 201))
})
app.delete('/:cloud/services/graphql/resources/:id/data-sources/:name', 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).deleteGraphqlDataSource(cloud, c.req.param('id'), c.req.param('name'))))
})
app.post('/:cloud/services/graphql/resources/:id/resolvers', 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).createGraphqlResolver(cloud, c.req.param('id'), await c.req.json<GraphqlResolverInput>()), 201))
})
app.delete('/:cloud/services/graphql/resources/:id/resolvers/:typeName/:fieldName', 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).deleteGraphqlResolver(cloud, c.req.param('id'), c.req.param('typeName'), c.req.param('fieldName'))))
})
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) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
@@ -93,6 +93,17 @@ import type {
UserPoolSignInTestResult,
UserPoolClientSettings,
DistributionInvalidation,
AutoScalingCapacityInput,
AccessPointInput,
CatalogTableInput,
QueryCatalog,
GraphqlApiKeyInput,
GraphqlDataSourceInput,
GraphqlResolverInput,
QueryExecutionSummary,
QueryInput,
QueryResult,
FileSystemAttachmentInput,
CertificatePem,
CertificateRevocationReason,
ExportedCertificate,
@@ -963,6 +974,138 @@ export class CloudProxyService {
return adapter.revokeCertificate(certificateId, reason)
}
async updateGraphqlSchema(cloud: CloudProvider, apiId: string, definition: string): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'graphql')
if (!adapter.updateGraphqlSchema) throw new NotSupportedError(`Saving schemas is not supported for ${cloud}/graphql`)
return adapter.updateGraphqlSchema(apiId, definition)
}
async createGraphqlApiKey(cloud: CloudProvider, apiId: string, input: GraphqlApiKeyInput): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'graphql')
if (!adapter.createGraphqlApiKey) throw new NotSupportedError(`API keys are not supported for ${cloud}/graphql`)
return adapter.createGraphqlApiKey(apiId, input)
}
async deleteGraphqlApiKey(cloud: CloudProvider, apiId: string, keyId: string): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'graphql')
if (!adapter.deleteGraphqlApiKey) throw new NotSupportedError(`API keys are not supported for ${cloud}/graphql`)
return adapter.deleteGraphqlApiKey(apiId, keyId)
}
async createGraphqlDataSource(cloud: CloudProvider, apiId: string, input: GraphqlDataSourceInput): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'graphql')
if (!adapter.createGraphqlDataSource) throw new NotSupportedError(`Data sources are not supported for ${cloud}/graphql`)
return adapter.createGraphqlDataSource(apiId, input)
}
async deleteGraphqlDataSource(cloud: CloudProvider, apiId: string, name: string): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'graphql')
if (!adapter.deleteGraphqlDataSource) throw new NotSupportedError(`Data sources are not supported for ${cloud}/graphql`)
return adapter.deleteGraphqlDataSource(apiId, name)
}
async createGraphqlResolver(cloud: CloudProvider, apiId: string, input: GraphqlResolverInput): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'graphql')
if (!adapter.createGraphqlResolver) throw new NotSupportedError(`Resolvers are not supported for ${cloud}/graphql`)
return adapter.createGraphqlResolver(apiId, input)
}
async deleteGraphqlResolver(cloud: CloudProvider, apiId: string, typeName: string, fieldName: string): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'graphql')
if (!adapter.deleteGraphqlResolver) throw new NotSupportedError(`Resolvers are not supported for ${cloud}/graphql`)
return adapter.deleteGraphqlResolver(apiId, typeName, fieldName)
}
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> {
const adapter = this.requireAdapter(cloud, 'userpools')
if (!adapter.describeUserPoolClient) throw new NotSupportedError(`App client settings are not supported for ${cloud}/userpools`)
+186
View File
@@ -155,6 +155,38 @@ export const apiEndpointKeys = {
export: "clouds.services.certificates.export",
revoke: "clouds.services.certificates.revoke",
},
graphql: {
schema: "clouds.services.graphql.schema",
apiKeys: "clouds.services.graphql.apiKeys",
apiKey: "clouds.services.graphql.apiKey",
dataSources: "clouds.services.graphql.dataSources",
dataSource: "clouds.services.graphql.dataSource",
resolvers: "clouds.services.graphql.resolvers",
resolver: "clouds.services.graphql.resolver",
},
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: {
enabled: "clouds.services.cdn.enabled",
invalidations: "clouds.services.cdn.invalidations",
@@ -952,6 +984,160 @@ export const endpointRegistry: EndpointRegistry = new Map([
method: "POST",
},
],
[
apiEndpointKeys.clouds.graphql.schema,
{
path: "/clouds/:cloud/services/graphql/resources/:id/schema",
method: "PUT",
},
],
[
apiEndpointKeys.clouds.graphql.apiKeys,
{
path: "/clouds/:cloud/services/graphql/resources/:id/api-keys",
method: "POST",
},
],
[
apiEndpointKeys.clouds.graphql.apiKey,
{
path: "/clouds/:cloud/services/graphql/resources/:id/api-keys/:keyId",
method: "DELETE",
},
],
[
apiEndpointKeys.clouds.graphql.dataSources,
{
path: "/clouds/:cloud/services/graphql/resources/:id/data-sources",
method: "POST",
},
],
[
apiEndpointKeys.clouds.graphql.dataSource,
{
path: "/clouds/:cloud/services/graphql/resources/:id/data-sources/:name",
method: "DELETE",
},
],
[
apiEndpointKeys.clouds.graphql.resolvers,
{
path: "/clouds/:cloud/services/graphql/resources/:id/resolvers",
method: "POST",
},
],
[
apiEndpointKeys.clouds.graphql.resolver,
{
path: "/clouds/:cloud/services/graphql/resources/:id/resolvers/:typeName/:fieldName",
method: "DELETE",
},
],
[
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,
{
@@ -33,6 +33,17 @@ import type {
UserPoolSignInTestResult,
UserPoolClientSettings,
DistributionInvalidation,
AutoScalingCapacityInput,
AccessPointInput,
CatalogTableInput,
QueryCatalog,
GraphqlApiKeyInput,
GraphqlDataSourceInput,
GraphqlResolverInput,
QueryExecutionSummary,
QueryInput,
QueryResult,
FileSystemAttachmentInput,
CertificatePem,
CertificateRevocationReason,
ExportedCertificate,
@@ -1720,6 +1731,293 @@ export async function revokeCertificate(
return res.data;
}
export async function updateGraphqlSchema(
cloud: CloudProvider,
apiId: string,
definition: string,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource, { definition: string }>(
apiEndpointKeys.clouds.graphql.schema,
requestOptions(cloud, "graphql", { body: { definition } }),
{ cloud, id: apiId },
);
return res.data;
}
export async function createGraphqlApiKey(
cloud: CloudProvider,
apiId: string,
input: GraphqlApiKeyInput,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource, GraphqlApiKeyInput>(
apiEndpointKeys.clouds.graphql.apiKeys,
requestOptions(cloud, "graphql", { body: input }),
{ cloud, id: apiId },
);
return res.data;
}
export async function deleteGraphqlApiKey(
cloud: CloudProvider,
apiId: string,
keyId: string,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource>(
apiEndpointKeys.clouds.graphql.apiKey,
requestOptions(cloud, "graphql"),
{ cloud, id: apiId, keyId },
);
return res.data;
}
export async function createGraphqlDataSource(
cloud: CloudProvider,
apiId: string,
input: GraphqlDataSourceInput,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource, GraphqlDataSourceInput>(
apiEndpointKeys.clouds.graphql.dataSources,
requestOptions(cloud, "graphql", { body: input }),
{ cloud, id: apiId },
);
return res.data;
}
export async function deleteGraphqlDataSource(
cloud: CloudProvider,
apiId: string,
name: string,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource>(
apiEndpointKeys.clouds.graphql.dataSource,
requestOptions(cloud, "graphql"),
{ cloud, id: apiId, name },
);
return res.data;
}
export async function createGraphqlResolver(
cloud: CloudProvider,
apiId: string,
input: GraphqlResolverInput,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource, GraphqlResolverInput>(
apiEndpointKeys.clouds.graphql.resolvers,
requestOptions(cloud, "graphql", { body: input }),
{ cloud, id: apiId },
);
return res.data;
}
export async function deleteGraphqlResolver(
cloud: CloudProvider,
apiId: string,
typeName: string,
fieldName: string,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource>(
apiEndpointKeys.clouds.graphql.resolver,
requestOptions(cloud, "graphql"),
{ cloud, id: apiId, typeName, fieldName },
);
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(
cloud: CloudProvider,
distributionId: string,
@@ -0,0 +1,85 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {beforeEach, expect, test, vi} from 'vitest'
import {AutoScalingPanel} from './AutoScalingPanel'
import type {CloudResource} from '@/types/resource'
const api = vi.hoisted(() => ({
getCloudResource: vi.fn(),
setAutoScalingCapacity: vi.fn(),
terminateAutoScalingInstance: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
function group(metadata: Record<string, unknown> = {}): CloudResource {
return {
id: 'web-asg', name: 'web-asg', cloud: 'aws', service: 'autoscaling', type: 'autoScalingGroup', region: 'us-east-1', createdAt: null, status: null,
metadata: {
minSize: 1, desiredCapacity: 2, maxSize: 3, capacity: '2 in service',
instances: [
{id: 'i-b8736c8b4a8c129a3', availabilityZone: 'us-east-1a', lifecycleState: 'InService', health: 'Healthy', instanceType: 't3.micro'},
{id: 'i-a3155c876867976b1', availabilityZone: 'us-east-1b', lifecycleState: 'InService', health: 'Healthy', instanceType: 't3.micro'},
],
activities: [{id: 'a1', description: 'Launching a new EC2 instance: i-b8736c8b4a8c129a3', cause: 'An instance was started in response to a desired capacity change.', status: 'Successful', statusMessage: null, startTime: null}],
...metadata,
},
}
}
function renderPanel(resource: CloudResource) {
api.getCloudResource.mockResolvedValue(resource)
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={client}><AutoScalingPanel resource={resource} runtimeReachable/></QueryClientProvider>)
}
beforeEach(() => {
vi.clearAllMocks()
api.setAutoScalingCapacity.mockImplementation(async (_cloud, _id, input) => group({desiredCapacity: input.desiredCapacity}))
api.terminateAutoScalingInstance.mockResolvedValue(undefined)
})
test('shows the instances and what launched them', async () => {
renderPanel(group())
expect(await screen.findByText('i-a3155c876867976b1')).toBeInTheDocument()
expect(screen.getByText('Launching a new EC2 instance: i-b8736c8b4a8c129a3')).toBeInTheDocument()
expect(screen.getByText(/never runs them/)).toBeInTheDocument()
})
test('changes the desired capacity', async () => {
const user = userEvent.setup()
renderPanel(group())
const form = await screen.findByRole('form', {name: 'Group capacity'})
const update = within(form).getByRole('button', {name: 'Update capacity'})
expect(update).toBeDisabled()
const desired = within(form).getByLabelText('Desired')
await user.clear(desired)
await user.type(desired, '3')
await user.click(update)
expect(api.setAutoScalingCapacity).toHaveBeenCalledWith('aws', 'web-asg', {minSize: 1, desiredCapacity: 3, maxSize: 3})
expect(await screen.findByText(/Capacity set to 3/)).toBeInTheDocument()
})
test('replaces an instance, or removes one while above the minimum', async () => {
const user = userEvent.setup()
vi.spyOn(window, 'confirm').mockReturnValue(true)
renderPanel(group())
await user.click(await screen.findByRole('button', {name: 'Replace i-b8736c8b4a8c129a3'}))
expect(api.terminateAutoScalingInstance).toHaveBeenCalledWith('aws', 'web-asg', 'i-b8736c8b4a8c129a3', true)
expect(await screen.findByText(/launches a replacement/)).toBeInTheDocument()
await user.click(screen.getByRole('button', {name: 'Remove i-a3155c876867976b1'}))
expect(api.terminateAutoScalingInstance).toHaveBeenLastCalledWith('aws', 'web-asg', 'i-a3155c876867976b1', false)
})
test('cannot remove an instance from a group at its minimum', async () => {
renderPanel(group({minSize: 2}))
expect(await screen.findByRole('button', {name: 'Remove i-b8736c8b4a8c129a3'})).toBeDisabled()
expect(screen.getByRole('button', {name: 'Replace i-b8736c8b4a8c129a3'})).toBeEnabled()
})
@@ -0,0 +1,146 @@
import {RefreshCw} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {getCloudResource, setAutoScalingCapacity, terminateAutoScalingInstance} from '@/api/cloudProxyClient'
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
import {useAccountId} from '@/lib/accountStore'
import {formatDateTime} from '@/lib/format'
import type {CloudResource} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
type Instance = {id: string; availabilityZone: string | null; lifecycleState: string | null; health: string | null; instanceType: string | null}
type Activity = {id: string; description: string; cause: string; status: string | null; statusMessage: string | null; startTime: string | null}
type Capacity = {minSize: string; desiredCapacity: string; maxSize: string}
/** The runtime reconciles every 10 seconds, so a group that is still moving is re-read this often. */
const SETTLING_POLL_MS = 4000
export function AutoScalingPanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [capacity, setCapacity] = useState<Capacity>({minSize: '', desiredCapacity: '', maxSize: ''})
const [notice, setNotice] = useState('')
const groupKey = ['autoscaling-group', accountId, resource?.id]
const groupQuery = useQuery({
queryKey: groupKey,
queryFn: ({signal}) => getCloudResource('aws', 'autoscaling', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
refetchInterval: (query) => query.state.data && !settled(query.state.data) ? SETTLING_POLL_MS : false,
})
const group = groupQuery.data ?? resource
useEffect(() => {
setNotice('')
}, [resource?.id])
// The form follows the group's saved capacity whenever it changes, here or elsewhere.
const savedMin = String(group?.metadata.minSize ?? '')
const savedDesired = String(group?.metadata.desiredCapacity ?? '')
const savedMax = String(group?.metadata.maxSize ?? '')
useEffect(() => {
setCapacity({minSize: savedMin, desiredCapacity: savedDesired, maxSize: savedMax})
}, [group?.id, savedMin, savedDesired, savedMax])
const refresh = () => Promise.all([
queryClient.invalidateQueries({queryKey: groupKey}),
queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources('aws', 'autoscaling')}),
])
const capacityMutation = useMutation({
mutationFn: () => setAutoScalingCapacity('aws', resource?.id ?? '', {
minSize: Number(capacity.minSize),
desiredCapacity: Number(capacity.desiredCapacity),
maxSize: Number(capacity.maxSize),
}),
onSuccess: (updated) => {
setNotice(`Capacity set to ${String(updated.metadata.desiredCapacity)}. TheCloud launches or terminates instances within about 10 seconds.`)
return refresh()
},
})
const terminateMutation = useMutation({
mutationFn: ({instanceId, replace}: {instanceId: string; replace: boolean}) => terminateAutoScalingInstance('aws', resource?.id ?? '', instanceId, replace),
onSuccess: (_result, {instanceId, replace}) => {
setNotice(replace ? `Terminating ${instanceId}. The group launches a replacement to stay at its desired capacity.` : `Terminating ${instanceId} and lowering the desired capacity by one.`)
return refresh()
},
})
if (!resource || !group) {
return <section className="table-panel"><div className="empty compact"><h3>Select an Auto Scaling group</h3><p>Choose a group to change its capacity, see its instances and follow its scaling activity.</p></div></section>
}
const metadata = group.metadata
const instances = Array.isArray(metadata.instances) ? metadata.instances as Instance[] : []
const activities = Array.isArray(metadata.activities) ? metadata.activities as Activity[] : []
const desired = Number(metadata.desiredCapacity ?? 0)
const min = Number(metadata.minSize ?? 0)
const pending = capacityMutation.isPending || terminateMutation.isPending
const disabled = !runtimeReachable || pending
const error = groupQuery.error ?? capacityMutation.error ?? terminateMutation.error
const changed = capacity.minSize !== String(metadata.minSize) || capacity.desiredCapacity !== String(metadata.desiredCapacity) || capacity.maxSize !== String(metadata.maxSize)
const field = (name: keyof Capacity, label: string) => <label className="dynamic-field"><span>{label}</span>
<input className="input" type="number" min={0} max={20} step={1} value={capacity[name]} onChange={(event) => setCapacity({...capacity, [name]: event.target.value})} disabled={disabled} required/>
</label>
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Auto Scaling group ${group.name}`}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Auto Scaling group</p><span className="muted">{String(metadata.capacity ?? '')} · desired {desired}{settled(group) ? '' : ' · scaling'}{group.status ? ` · ${group.status}` : ''}</span></div>
<div className="cdn-actions">
<button className="button" type="button" disabled={!runtimeReachable || groupQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{groupQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
</div>
<form className="dynamic-form autoscaling-capacity" aria-label="Group capacity" onSubmit={(event) => { event.preventDefault(); setNotice(''); capacityMutation.mutate() }}>
{field('minSize', 'Minimum')}
{field('desiredCapacity', 'Desired')}
{field('maxSize', 'Maximum')}
<div><button className="button primary" type="submit" disabled={disabled || !changed}>{capacityMutation.isPending ? 'Updating' : 'Update capacity'}</button></div>
</form>
<p className="muted compact-text autoscaling-note">Scaling policies are not offered: TheCloud stores EC2 scaling policies but never runs them, so set the capacity here. Alarm-driven scaling works for ECS services through Application Auto Scaling.</p>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Instances</p><span className="muted">Each is a real EC2 instance in TheCloud, launched from the group&apos;s template.</span></div></div>
{instances.length === 0 ? <p className="muted compact-text autoscaling-note">No instances. {desired > 0 ? 'The group is launching them.' : 'Raise the desired capacity to launch some.'}</p>
: <div className="cognito-table"><table className="table"><thead><tr><th>Instance</th><th>State</th><th>Health</th><th>Zone</th><th>Type</th><th aria-label="Actions"/></tr></thead><tbody>{instances.map((instance) => (
<tr key={instance.id}>
<td className="mono">{instance.id}</td>
<td><code>{instance.lifecycleState ?? '—'}</code></td>
<td>{instance.health ?? '—'}</td>
<td>{instance.availabilityZone ?? '—'}</td>
<td>{instance.instanceType ?? '—'}</td>
<td className="autoscaling-instance-actions">
<button className="button compact" type="button" disabled={disabled || instance.lifecycleState !== 'InService'} aria-label={`Replace ${instance.id}`} onClick={() => { setNotice(''); terminateMutation.mutate({instanceId: instance.id, replace: true}) }}>Replace</button>
<button className="button compact danger" type="button" disabled={disabled || instance.lifecycleState !== 'InService' || desired - 1 < min} title={desired - 1 < min ? 'The group is at its minimum size' : undefined} aria-label={`Remove ${instance.id}`} onClick={() => {
setNotice('')
if (window.confirm(`Terminate ${instance.id} and lower the desired capacity to ${desired - 1}?`)) terminateMutation.mutate({instanceId: instance.id, replace: false})
}}>Remove</button>
</td>
</tr>
))}</tbody></table></div>}
{activities.length > 0 && <>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Activity</p><span className="muted">The most recent launches and terminations.</span></div></div>
<div className="cognito-table"><table className="table"><thead><tr><th>Started</th><th>Status</th><th>Activity</th></tr></thead><tbody>{activities.map((activity) => (
<tr key={activity.id}>
<td>{formatDateTime(activity.startTime) ?? '—'}</td>
<td><code>{activity.status ?? '—'}</code></td>
<td>{activity.description}<br/><small className="muted">{activity.statusMessage || activity.cause}</small></td>
</tr>
))}</tbody></table></div>
</>}
{metadata.activitiesUnavailable === true && <p className="muted compact-text autoscaling-note">The scaling activity could not be read just now.</p>}
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
</section>
}
/** Settled once every desired instance is in service and none is still starting or stopping. */
function settled(group: CloudResource): boolean {
const instances = Array.isArray(group.metadata.instances) ? group.metadata.instances as Instance[] : []
const inService = instances.filter((instance) => instance.lifecycleState === 'InService').length
return inService === Number(group.metadata.desiredCapacity ?? 0) && instances.every((instance) => instance.lifecycleState === 'InService')
}
@@ -0,0 +1,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,6 +68,11 @@ import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPane
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
import { CloudFrontPanel } from "@/components/CloudFrontPanel";
import { AutoScalingPanel } from "@/components/AutoScalingPanel";
import { FileSystemPanel } from "@/components/FileSystemPanel";
import { DataCatalogPanel } from "@/components/DataCatalogPanel";
import { QueryPanel } from "@/components/QueryPanel";
import { GraphqlPanel } from "@/components/GraphqlPanel";
import { CertificatePanel } from "@/components/CertificatePanel";
import { PipePanel } from "@/components/PipePanel";
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
@@ -995,6 +1000,21 @@ export function DynamicResourceView({
{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 === "graphql" && cloud === "aws" && (
<GraphqlPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "cdn" && cloud === "aws" && (
<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&apos;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,142 @@
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 {GraphqlPanel} from './GraphqlPanel'
import type {CloudResource} from '@/types/resource'
const api = vi.hoisted(() => ({
getCloudResource: vi.fn(),
listCloudResources: vi.fn(),
updateGraphqlSchema: vi.fn(),
createGraphqlApiKey: vi.fn(),
deleteGraphqlApiKey: vi.fn(),
createGraphqlDataSource: vi.fn(),
deleteGraphqlDataSource: vi.fn(),
createGraphqlResolver: vi.fn(),
deleteGraphqlResolver: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
const sdl = 'type Note { id: ID! text: String }\ntype Query { note(id: ID!): Note notes: [Note] }'
function graphqlApi(metadata: Record<string, unknown> = {}): CloudResource {
return {
id: 'a1', name: 'notes-api', cloud: 'aws', service: 'graphql', type: 'graphqlApi', region: 'us-east-1', createdAt: null,
metadata: {
apiId: 'a1', authenticationType: 'API_KEY', authenticationLabel: 'API key',
graphqlUrl: 'http://localhost:4566/v1/apis/a1/graphql', realtimeUrl: 'ws://localhost:4566/v1/apis/a1/graphql/realtime',
definition: sdl, schemaStatus: {status: 'SUCCESS', details: null},
types: [{name: 'Query', fields: ['note', 'notes']}, {name: 'Note', fields: ['id', 'text']}],
apiKeys: [{id: 'da2-key', description: 'web', expiresAt: '2026-11-01T22:00:00.000Z'}],
dataSources: [{name: 'notesTable', type: 'AMAZON_DYNAMODB', description: null, tableName: 'notes', functionArn: null, serviceRoleArn: 'arn:aws:iam::000000000000:role/appsync'}],
resolvers: [{typeName: 'Query', fieldName: 'note', dataSourceName: 'notesTable', kind: 'UNIT', runtime: 'APPSYNC_JS', code: ''}],
...metadata,
},
}
}
function renderPanel(resource: CloudResource) {
api.getCloudResource.mockResolvedValue(resource)
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={client}><GraphqlPanel resource={resource} runtimeReachable/></QueryClientProvider>)
}
beforeEach(() => {
vi.clearAllMocks()
api.listCloudResources.mockResolvedValue([{id: 'notes', name: 'notes', metadata: {}}])
for (const fn of [api.updateGraphqlSchema, api.createGraphqlApiKey, api.deleteGraphqlApiKey, api.createGraphqlDataSource, api.deleteGraphqlDataSource, api.createGraphqlResolver, api.deleteGraphqlResolver]) {
fn.mockResolvedValue(graphqlApi())
}
})
test('shows the endpoint, saved schema, keys, data sources and resolvers, and says resolvers do not run', async () => {
renderPanel(graphqlApi())
expect(await screen.findByText('http://localhost:4566/v1/apis/a1/graphql')).toBeInTheDocument()
expect(screen.getByLabelText('Schema definition')).toHaveValue(sdl)
expect(screen.getByText('da2-key')).toBeInTheDocument()
expect(screen.getByText('Query.note')).toBeInTheDocument()
expect(screen.getByText(/does not run resolvers yet/)).toBeInTheDocument()
})
test('offers a starter schema for a new API and saves an edited one', async () => {
const user = userEvent.setup()
renderPanel(graphqlApi({definition: null, types: [], resolvers: [], schemaStatus: null}))
const schema = await screen.findByLabelText('Schema definition')
expect(await screen.findByText(/This starter schema is not saved/)).toBeInTheDocument()
await user.clear(schema)
await user.type(schema, 'type Query {{ hello: String }')
await user.click(screen.getByRole('button', {name: 'Save schema'}))
expect(api.updateGraphqlSchema).toHaveBeenCalledWith('aws', 'a1', 'type Query { hello: String }')
expect(await screen.findByText('Schema saved.')).toBeInTheDocument()
})
test('saves the starter schema as shown when it is not edited', async () => {
const user = userEvent.setup()
renderPanel(graphqlApi({definition: null, types: [], resolvers: [], schemaStatus: null}))
await screen.findByText(/This starter schema is not saved/)
await user.click(screen.getByRole('button', {name: 'Save schema'}))
expect(api.updateGraphqlSchema).toHaveBeenCalledWith('aws', 'a1', expect.stringContaining('type Query {'))
})
test('creates an API key that lasts the chosen days', async () => {
const user = userEvent.setup()
renderPanel(graphqlApi())
const form = await screen.findByRole('form', {name: 'Create API key'})
await user.type(within(form).getByLabelText('Description'), 'mobile')
const days = within(form).getByLabelText('Expires in (days)')
await user.clear(days)
await user.type(days, '30')
await user.click(within(form).getByRole('button', {name: 'Create API key'}))
expect(api.createGraphqlApiKey).toHaveBeenCalledWith('aws', 'a1', {description: 'mobile', expiresInDays: 30})
})
test('creates a DynamoDB data source from a chosen table and role', async () => {
const user = userEvent.setup()
renderPanel(graphqlApi())
const form = await screen.findByRole('form', {name: 'Create data source'})
await user.type(within(form).getByLabelText('Name'), 'notesStore')
await user.selectOptions(within(form).getByLabelText('Type'), 'AMAZON_DYNAMODB')
await user.selectOptions(within(form).getByLabelText('Table'), await within(form).findByRole('option', {name: 'notes'}))
await user.type(within(form).getByLabelText('Service role ARN'), 'arn:aws:iam::000000000000:role/appsync')
await user.click(within(form).getByRole('button', {name: 'Create data source'}))
expect(api.createGraphqlDataSource).toHaveBeenCalledWith('aws', 'a1', {name: 'notesStore', type: 'AMAZON_DYNAMODB', tableName: 'notes', serviceRoleArn: 'arn:aws:iam::000000000000:role/appsync'})
})
test('attaches a resolver to a field without one, starting from the data source template', async () => {
const user = userEvent.setup()
renderPanel(graphqlApi())
const form = await screen.findByRole('form', {name: 'Attach resolver'})
await user.selectOptions(within(form).getByLabelText('Type'), 'Query')
const field = within(form).getByLabelText('Field')
expect(within(field).queryByRole('option', {name: 'note'})).not.toBeInTheDocument()
await user.selectOptions(field, 'notes')
await user.selectOptions(within(form).getByLabelText('Data source'), 'notesTable')
expect((within(form).getByLabelText('Resolver code') as HTMLTextAreaElement).value).toContain("@aws-appsync/utils/dynamodb")
await user.click(within(form).getByRole('button', {name: 'Attach resolver'}))
expect(api.createGraphqlResolver).toHaveBeenCalledWith('aws', 'a1', expect.objectContaining({typeName: 'Query', fieldName: 'notes', dataSourceName: 'notesTable'}))
expect(await screen.findByText('Attached a resolver to Query.notes.')).toBeInTheDocument()
})
test('deletes a key and detaches a resolver after confirming', async () => {
const user = userEvent.setup()
vi.spyOn(window, 'confirm').mockReturnValue(true)
renderPanel(graphqlApi())
await user.click(await screen.findByRole('button', {name: 'Delete da2-key'}))
expect(api.deleteGraphqlApiKey).toHaveBeenCalledWith('aws', 'a1', 'da2-key')
await user.click(screen.getByRole('button', {name: 'Detach Query.note'}))
expect(api.deleteGraphqlResolver).toHaveBeenCalledWith('aws', 'a1', 'Query', 'note')
})
@@ -0,0 +1,295 @@
import {Copy, RefreshCw} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {
createGraphqlApiKey,
createGraphqlDataSource,
createGraphqlResolver,
deleteGraphqlApiKey,
deleteGraphqlDataSource,
deleteGraphqlResolver,
getCloudResource,
listCloudResources,
updateGraphqlSchema,
} from '@/api/cloudProxyClient'
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
import {useAccountId} from '@/lib/accountStore'
import {formatDateTime} from '@/lib/format'
import type {CloudResource, GraphqlDataSourceInput} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
type ApiKey = {id: string; description: string | null; expiresAt: string | null}
type DataSource = {name: string; type: string | null; description: string | null; tableName: string | null; functionArn: string | null; serviceRoleArn: string | null}
type Resolver = {typeName: string; fieldName: string; dataSourceName: string | null; kind: string | null; runtime: string | null; code: string | null}
type GraphqlType = {name: string; fields: string[]}
type SourceForm = {name: string; type: GraphqlDataSourceInput['type']; tableName: string; functionArn: string; serviceRoleArn: string}
const STARTER_SCHEMA = `type Note {
id: ID!
text: String
}
type Query {
note(id: ID!): Note
}
type Mutation {
addNote(id: ID!, text: String!): Note
}
schema {
query: Query
mutation: Mutation
}
`
const SOURCE_LABELS: Record<string, string> = {NONE: 'None (local resolver)', AMAZON_DYNAMODB: 'DynamoDB table', AWS_LAMBDA: 'Lambda function'}
const EMPTY_SOURCE: SourceForm = {name: '', type: 'NONE', tableName: '', functionArn: '', serviceRoleArn: ''}
/** The resolver AppSync's console starts from for each kind of data source. */
function starterCode(sourceType: string | null | undefined): string {
if (sourceType === 'AMAZON_DYNAMODB') {
return `import * as ddb from '@aws-appsync/utils/dynamodb'
export function request(ctx) {
return ddb.get({ key: { id: ctx.args.id } })
}
export function response(ctx) {
return ctx.result
}
`
}
if (sourceType === 'AWS_LAMBDA') {
return `export function request(ctx) {
return { operation: 'Invoke', payload: { field: ctx.info.fieldName, arguments: ctx.args } }
}
export function response(ctx) {
return ctx.result
}
`
}
return `export function request(ctx) {
return { payload: ctx.args }
}
export function response(ctx) {
return ctx.result
}
`
}
export function GraphqlPanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [definition, setDefinition] = useState<string | null>(null)
const [keyForm, setKeyForm] = useState({description: '', days: '7'})
const [sourceForm, setSourceForm] = useState<SourceForm>(EMPTY_SOURCE)
const [resolverForm, setResolverForm] = useState({typeName: '', fieldName: '', dataSourceName: '', code: ''})
const [notice, setNotice] = useState('')
const detailKey = ['appsync-api', accountId, resource?.id]
const detailQuery = useQuery({
queryKey: detailKey,
queryFn: ({signal}) => getCloudResource('aws', 'graphql', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
})
const tablesQuery = useQuery({
queryKey: cloudQueryKeys.resources('aws', 'nosql'),
queryFn: ({signal}) => listCloudResources('aws', 'nosql', undefined, signal),
enabled: Boolean(resource) && runtimeReachable && sourceForm.type === 'AMAZON_DYNAMODB',
})
const functionsQuery = useQuery({
queryKey: cloudQueryKeys.resources('aws', 'serverless'),
queryFn: ({signal}) => listCloudResources('aws', 'serverless', undefined, signal),
enabled: Boolean(resource) && runtimeReachable && sourceForm.type === 'AWS_LAMBDA',
})
useEffect(() => {
setNotice('')
setDefinition(null)
setKeyForm({description: '', days: '7'})
setSourceForm(EMPTY_SOURCE)
setResolverForm({typeName: '', fieldName: '', dataSourceName: '', code: ''})
}, [resource?.id])
const id = resource?.id ?? ''
const settle = (updated: CloudResource, message: string) => {
queryClient.setQueryData(detailKey, updated)
setNotice(message)
}
const saveSchema = useMutation({
mutationFn: (text: string) => updateGraphqlSchema('aws', id, text),
onSuccess: (updated) => { setDefinition(null); settle(updated, 'Schema saved.') },
})
const addKey = useMutation({
mutationFn: () => createGraphqlApiKey('aws', id, {...(keyForm.description.trim() ? {description: keyForm.description.trim()} : {}), expiresInDays: Number(keyForm.days)}),
onSuccess: (updated) => { setKeyForm({description: '', days: '7'}); settle(updated, 'Created an API key.') },
})
const removeKey = useMutation({
mutationFn: (keyId: string) => deleteGraphqlApiKey('aws', id, keyId),
onSuccess: (updated, keyId) => settle(updated, `Deleted API key ${keyId}.`),
})
const addSource = useMutation({
mutationFn: () => createGraphqlDataSource('aws', id, {
name: sourceForm.name.trim(),
type: sourceForm.type,
...(sourceForm.type === 'AMAZON_DYNAMODB' ? {tableName: sourceForm.tableName} : {}),
...(sourceForm.type === 'AWS_LAMBDA' ? {functionArn: sourceForm.functionArn} : {}),
...(sourceForm.type !== 'NONE' ? {serviceRoleArn: sourceForm.serviceRoleArn.trim()} : {}),
}),
onSuccess: (updated) => { const name = sourceForm.name.trim(); setSourceForm(EMPTY_SOURCE); settle(updated, `Created data source ${name}.`) },
})
const removeSource = useMutation({
mutationFn: (name: string) => deleteGraphqlDataSource('aws', id, name),
onSuccess: (updated, name) => settle(updated, `Deleted data source ${name}.`),
})
const addResolver = useMutation({
mutationFn: () => createGraphqlResolver('aws', id, resolverForm),
onSuccess: (updated) => {
const field = `${resolverForm.typeName}.${resolverForm.fieldName}`
setResolverForm({typeName: '', fieldName: '', dataSourceName: '', code: ''})
settle(updated, `Attached a resolver to ${field}.`)
},
})
const removeResolver = useMutation({
mutationFn: (resolver: Resolver) => deleteGraphqlResolver('aws', id, resolver.typeName, resolver.fieldName),
onSuccess: (updated, resolver) => settle(updated, `Detached the resolver from ${resolver.typeName}.${resolver.fieldName}.`),
})
if (!resource) {
return <section className="table-panel"><div className="empty compact"><h3>Select a GraphQL API</h3><p>Choose an API to edit its schema, keys, data sources and resolvers.</p></div></section>
}
const api = detailQuery.data ?? resource
const metadata = api.metadata
const savedDefinition = typeof metadata.definition === 'string' ? metadata.definition : ''
const schemaText = definition ?? (savedDefinition || (detailQuery.data ? STARTER_SCHEMA : ''))
const schemaStatus = metadata.schemaStatus as {status: string; details: string | null} | null | undefined
const apiKeys = Array.isArray(metadata.apiKeys) ? metadata.apiKeys as ApiKey[] : []
const dataSources = Array.isArray(metadata.dataSources) ? metadata.dataSources as DataSource[] : []
const resolvers = Array.isArray(metadata.resolvers) ? metadata.resolvers as Resolver[] : []
const types = Array.isArray(metadata.types) ? metadata.types as GraphqlType[] : []
const chosenType = types.find((type) => type.name === resolverForm.typeName)
const openFields = (chosenType?.fields ?? []).filter((field) => !resolvers.some((resolver) => resolver.typeName === chosenType?.name && resolver.fieldName === field))
const mutations = [saveSchema, addKey, removeKey, addSource, removeSource, addResolver, removeResolver]
const disabled = !runtimeReachable || mutations.some((mutation) => mutation.isPending)
const error = detailQuery.error ?? mutations.find((mutation) => mutation.error)?.error
const run = (action: () => void) => { setNotice(''); action() }
const copy = (value: string) => { void navigator.clipboard?.writeText(value) }
const chooseSource = (dataSourceName: string) => {
const source = dataSources.find((candidate) => candidate.name === dataSourceName)
const starterOfCurrent = starterCode(dataSources.find((candidate) => candidate.name === resolverForm.dataSourceName)?.type)
const keepCode = resolverForm.code && resolverForm.code !== starterOfCurrent
setResolverForm({...resolverForm, dataSourceName, code: keepCode ? resolverForm.code : starterCode(source?.type)})
}
return <section className="table-panel" style={{marginTop: 16}} aria-label={`GraphQL API ${api.name}`}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">AppSync GraphQL API</p><span className="muted">{String(metadata.authenticationLabel ?? '')} authorization</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>
<dl className="graphql-endpoints">
{typeof metadata.graphqlUrl === 'string' && <><dt>GraphQL endpoint</dt><dd><code>{metadata.graphqlUrl}</code> <button className="button compact" type="button" aria-label="Copy GraphQL endpoint" onClick={() => copy(String(metadata.graphqlUrl))}><Copy size={12}/></button></dd></>}
{typeof metadata.realtimeUrl === 'string' && <><dt>Real-time endpoint</dt><dd><code>{metadata.realtimeUrl}</code></dd></>}
</dl>
<p className="muted compact-text graphql-note">TheCloud keeps the schema, keys, data sources and resolvers, and its endpoint checks each query against the schema and the caller&apos;s key. It does not run resolvers yet, so every field of a query resolves to null.</p>
<form className="graphql-section" aria-label="Schema" onSubmit={(event) => { event.preventDefault(); run(() => saveSchema.mutate(schemaText)) }}>
<div className="graphql-section-head"><p className="eyebrow">Schema</p>{schemaStatus && <code className={`query-state query-state--${schemaStatus.status === 'SUCCESS' ? 'succeeded' : schemaStatus.status === 'FAILED' ? 'failed' : 'running'}`}>{schemaStatus.status}</code>}</div>
<textarea className="textarea mono graphql-schema" aria-label="Schema definition" value={schemaText} spellCheck={false} rows={14} onChange={(event) => setDefinition(event.target.value)} disabled={disabled}/>
{!savedDefinition && detailQuery.data && <small className="muted">No schema yet. This starter schema is not saved until you save it.</small>}
<div><button className="button primary" type="submit" disabled={disabled || !schemaText.trim() || (definition === null && Boolean(savedDefinition))}>{saveSchema.isPending ? 'Saving' : 'Save schema'}</button></div>
</form>
<div className="input-row resource-table-bar"><div><p className="eyebrow">API keys</p><span className="muted">Clients send one in the x-api-key header{metadata.authenticationType === 'API_KEY' ? '.' : ' when API keys are an authorization mode.'}</span></div></div>
{apiKeys.length === 0 ? <p className="muted compact-text graphql-note">No API keys.</p>
: <div className="cognito-table"><table className="table"><thead><tr><th>Key</th><th>Description</th><th>Expires</th><th aria-label="Actions"/></tr></thead><tbody>{apiKeys.map((key) => (
<tr key={key.id}>
<td className="mono">{key.id} <button className="button compact" type="button" aria-label={`Copy ${key.id}`} onClick={() => copy(key.id)}><Copy size={12}/></button></td>
<td>{key.description ?? '—'}</td>
<td>{formatDateTime(key.expiresAt) ?? '—'}</td>
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Delete ${key.id}`} onClick={() => run(() => {
if (window.confirm(`Delete API key ${key.id}? Clients using it are refused.`)) removeKey.mutate(key.id)
})}>Delete</button></td>
</tr>
))}</tbody></table></div>}
<form className="dynamic-form graphql-inline-form" aria-label="Create API key" onSubmit={(event) => { event.preventDefault(); run(() => addKey.mutate()) }}>
<label className="dynamic-field"><span>Description</span><input className="input" value={keyForm.description} onChange={(event) => setKeyForm({...keyForm, description: event.target.value})} disabled={disabled}/></label>
<label className="dynamic-field"><span>Expires in (days)</span><input className="input" type="number" min={1} max={365} value={keyForm.days} onChange={(event) => setKeyForm({...keyForm, days: event.target.value})} disabled={disabled} required/></label>
<div><button className="button" type="submit" disabled={disabled}>{addKey.isPending ? 'Creating' : 'Create API key'}</button></div>
</form>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Data sources</p><span className="muted">What resolvers read from and write to.</span></div></div>
{dataSources.length === 0 ? <p className="muted compact-text graphql-note">No data sources yet.</p>
: <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Type</th><th>Target</th><th aria-label="Actions"/></tr></thead><tbody>{dataSources.map((source) => (
<tr key={source.name}>
<td className="mono">{source.name}</td>
<td>{SOURCE_LABELS[source.type ?? ''] ?? source.type ?? '—'}</td>
<td className="mono graphql-target">{source.tableName ?? source.functionArn ?? '—'}</td>
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Delete data source ${source.name}`} onClick={() => run(() => {
if (window.confirm(`Delete data source ${source.name}?`)) removeSource.mutate(source.name)
})}>Delete</button></td>
</tr>
))}</tbody></table></div>}
<form className="dynamic-form graphql-source-form" aria-label="Create data source" onSubmit={(event) => { event.preventDefault(); run(() => addSource.mutate()) }}>
<label className="dynamic-field"><span>Name</span><input className="input mono" value={sourceForm.name} onChange={(event) => setSourceForm({...sourceForm, name: event.target.value})} disabled={disabled} required pattern="[_A-Za-z][_0-9A-Za-z]*" title="Letters, numbers and underscores, starting with a letter or underscore"/></label>
<label className="dynamic-field"><span>Type</span><select className="input" value={sourceForm.type} onChange={(event) => setSourceForm({...sourceForm, type: event.target.value as SourceForm['type']})} disabled={disabled}>
{Object.entries(SOURCE_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select></label>
{sourceForm.type === 'AMAZON_DYNAMODB' && <label className="dynamic-field"><span>Table</span><select className="input" value={sourceForm.tableName} onChange={(event) => setSourceForm({...sourceForm, tableName: event.target.value})} disabled={disabled} required>
<option value="">{tablesQuery.data?.length ? 'Choose a table' : 'No tables yet'}</option>
{(tablesQuery.data ?? []).map((table) => <option key={table.id} value={table.name}>{table.name}</option>)}
</select></label>}
{sourceForm.type === 'AWS_LAMBDA' && <label className="dynamic-field"><span>Function</span><select className="input" value={sourceForm.functionArn} onChange={(event) => setSourceForm({...sourceForm, functionArn: event.target.value})} disabled={disabled} required>
<option value="">{functionsQuery.data?.length ? 'Choose a function' : 'No functions yet'}</option>
{(functionsQuery.data ?? []).filter((fn) => typeof fn.metadata.arn === 'string').map((fn) => <option key={fn.id} value={String(fn.metadata.arn)}>{fn.name}</option>)}
</select></label>}
{sourceForm.type !== 'NONE' && <label className="dynamic-field graphql-span"><span>Service role ARN</span><input className="input mono" value={sourceForm.serviceRoleArn} placeholder="arn:aws:iam::000000000000:role/appsync-access" onChange={(event) => setSourceForm({...sourceForm, serviceRoleArn: event.target.value})} disabled={disabled} required pattern="arn:aws[a-z\-]*:iam::\d{12}:role/.+" title="The IAM role AppSync assumes, as AWS requires"/></label>}
<div><button className="button" type="submit" disabled={disabled}>{addSource.isPending ? 'Creating' : 'Create data source'}</button></div>
</form>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Resolvers</p><span className="muted">JavaScript (APPSYNC_JS) resolvers attached to fields of the schema.</span></div></div>
{resolvers.length === 0 ? <p className="muted compact-text graphql-note">No resolvers yet.</p>
: <div className="cognito-table"><table className="table"><thead><tr><th>Field</th><th>Data source</th><th>Runtime</th><th aria-label="Actions"/></tr></thead><tbody>{resolvers.map((resolver) => (
<tr key={`${resolver.typeName}.${resolver.fieldName}`}>
<td className="mono">{resolver.typeName}.{resolver.fieldName}</td>
<td className="mono">{resolver.dataSourceName ?? '—'}</td>
<td>{resolver.runtime ?? '—'}</td>
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Detach ${resolver.typeName}.${resolver.fieldName}`} onClick={() => run(() => {
if (window.confirm(`Detach the resolver from ${resolver.typeName}.${resolver.fieldName}?`)) removeResolver.mutate(resolver)
})}>Detach</button></td>
</tr>
))}</tbody></table></div>}
{types.length === 0 ? <p className="muted compact-text graphql-note">Save a schema to attach resolvers to its fields.</p>
: dataSources.length === 0 ? <p className="muted compact-text graphql-note">Create a data source to attach resolvers.</p>
: <form className="graphql-section" aria-label="Attach resolver" onSubmit={(event) => { event.preventDefault(); run(() => addResolver.mutate()) }}>
<div className="dynamic-form graphql-source-form">
<label className="dynamic-field"><span>Type</span><select className="input" value={resolverForm.typeName} onChange={(event) => setResolverForm({...resolverForm, typeName: event.target.value, fieldName: ''})} disabled={disabled} required>
<option value="">Choose a type</option>
{types.map((type) => <option key={type.name} value={type.name}>{type.name}</option>)}
</select></label>
<label className="dynamic-field"><span>Field</span><select className="input" value={resolverForm.fieldName} onChange={(event) => setResolverForm({...resolverForm, fieldName: event.target.value})} disabled={disabled || !chosenType} required>
<option value="">{chosenType && openFields.length === 0 ? 'Every field has a resolver' : 'Choose a field'}</option>
{openFields.map((field) => <option key={field} value={field}>{field}</option>)}
</select></label>
<label className="dynamic-field"><span>Data source</span><select className="input" value={resolverForm.dataSourceName} onChange={(event) => chooseSource(event.target.value)} disabled={disabled} required>
<option value="">Choose a data source</option>
{dataSources.map((source) => <option key={source.name} value={source.name}>{source.name}</option>)}
</select></label>
</div>
<textarea className="textarea mono graphql-code" aria-label="Resolver code" value={resolverForm.code} spellCheck={false} rows={10} placeholder="Choose a data source to start from its template." onChange={(event) => setResolverForm({...resolverForm, code: event.target.value})} disabled={disabled} required/>
<div><button className="button" type="submit" disabled={disabled}>{addResolver.isPending ? 'Attaching' : 'Attach resolver'}</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>
}
@@ -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
}
@@ -1,12 +1,14 @@
import {
Activity,
Boxes,
Braces,
Circle,
Clock,
Container,
Database,
Earth,
FileBadge,
FolderTree,
Globe,
HardDrive,
Hammer,
@@ -16,6 +18,8 @@ import {
MessageSquare,
Network,
Radio,
Scaling,
SearchCode,
ScrollText,
Scale,
Server,
@@ -37,6 +41,11 @@ import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy
import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg'
import route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/Route-53.svg'
import cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Delivery/CloudFront.svg'
import autoScalingIcon from '../../../../aws-icons-svg/Compute/EC2-Auto-Scaling.svg'
import 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 appSyncIcon from '../../../../aws-icons-svg/App-Integration/AppSync.svg'
import certificateManagerIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Certificate-Manager.svg'
import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg'
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
@@ -98,6 +107,11 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
userpools: UsersRound,
dns: Globe,
cdn: Earth,
autoscaling: Scaling,
filesystems: FolderTree,
datacatalog: Table2,
query: SearchCode,
graphql: Braces,
certificates: FileBadge,
monitoring: Activity,
loadbalancing: Scale,
@@ -147,6 +161,11 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
userpools: cognitoIcon,
dns: route53Icon,
cdn: cloudFrontIcon,
autoscaling: autoScalingIcon,
filesystems: efsIcon,
datacatalog: glueIcon,
query: athenaIcon,
graphql: appSyncIcon,
certificates: certificateManagerIcon,
monitoring: cloudWatchIcon,
workflows: stepFunctionsIcon,
+66
View File
@@ -6625,3 +6625,69 @@ tr[aria-current="true"] td { background: var(--hover-md); }
.pipe-arn, .pipe-code { overflow-wrap: anywhere; }
.pipe-code { font-family: var(--mono); font-size: 11px; white-space: pre-wrap; }
.pipe-role, .pipe-reason { padding: 0 16px 12px; margin: 0; overflow-wrap: anywhere; }
/* EC2 Auto Scaling: capacity fields side by side, instance actions in one row. */
.autoscaling-capacity { grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); align-items: end; padding: 4px 16px 12px; }
.autoscaling-note { padding: 0 16px 12px; margin: 0; }
.autoscaling-instance-actions { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; }
/* 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); } }
/* AppSync: endpoints as a definition list, schema and resolver code as tall editors. */
.graphql-endpoints { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 12px; margin: 0; padding: 12px 16px 4px; }
.graphql-endpoints dt { color: var(--text-2); font-size: 11px; font-weight: 600; text-transform: uppercase; align-self: center; }
.graphql-endpoints dd { margin: 0; overflow-wrap: anywhere; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.graphql-note { padding: 0 16px 12px; margin: 0; }
.graphql-section { display: grid; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); }
.graphql-section-head { display: flex; align-items: center; gap: 10px; }
.graphql-section-head p { margin: 0; }
.graphql-schema, .graphql-code { min-height: 180px; resize: vertical; }
.graphql-inline-form { grid-template-columns: repeat(auto-fit, minmax(160px, 260px)); align-items: end; padding: 4px 16px 12px; }
.graphql-source-form { grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); align-items: end; padding: 4px 16px 12px; }
.graphql-section .graphql-source-form { padding: 0; }
.graphql-span { grid-column: span 2; }
.graphql-target { overflow-wrap: anywhere; }
@media (max-width: 640px) { .graphql-span { grid-column: auto; } .graphql-endpoints { grid-template-columns: minmax(0, 1fr); } }
+89
View File
@@ -356,6 +356,95 @@ export type CertificateRevocationReason =
| 'CESSATION_OF_OPERATION'
| 'PRIVILEGE_WITHDRAWN'
export interface AutoScalingCapacityInput {
minSize?: number
desiredCapacity?: number
maxSize?: number
}
export interface GraphqlApiKeyInput {
description?: string
expiresInDays?: number
}
export interface GraphqlDataSourceInput {
name: string
description?: string
type: 'NONE' | 'AMAZON_DYNAMODB' | 'AWS_LAMBDA'
tableName?: string
functionArn?: string
serviceRoleArn?: string
}
export interface GraphqlResolverInput {
typeName: string
fieldName: string
dataSourceName: string
code: string
}
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 {
id: string
status: string
+10
View File
@@ -28,7 +28,12 @@ export type KubernetesActionName =
| 'deleteFargateProfile'
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
export type CertificateActionName = 'download' | 'export' | 'revoke'
export type GraphqlActionName = 'updateSchema' | 'createApiKey' | 'deleteApiKey' | 'createDataSource' | 'deleteDataSource' | 'createResolver' | 'deleteResolver'
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 UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth'
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
@@ -92,7 +97,12 @@ export interface ServiceSchema {
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
distributionActions?: Array<CapabilitySchema<DistributionActionName>>
autoScalingActions?: Array<CapabilitySchema<AutoScalingActionName>>
certificateActions?: Array<CapabilitySchema<CertificateActionName>>
fileSystemActions?: Array<CapabilitySchema<FileSystemActionName>>
dataCatalogActions?: Array<CapabilitySchema<DataCatalogActionName>>
queryActions?: Array<CapabilitySchema<QueryActionName>>
graphqlActions?: Array<CapabilitySchema<GraphqlActionName>>
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
}
filters: FieldSchema[]
+209 -119
View File
@@ -22,6 +22,15 @@ importers:
'@aws-sdk/client-appconfig':
specifier: ^3.1076.0
version: 3.1130.0
'@aws-sdk/client-appsync':
specifier: ^3.1146.0
version: 3.1156.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':
specifier: ^3.1111.0
version: 3.1111.0
@@ -58,6 +67,9 @@ importers:
'@aws-sdk/client-ecs':
specifier: ^3.1076.0
version: 3.1145.0
'@aws-sdk/client-efs':
specifier: ^3.1146.0
version: 3.1146.0
'@aws-sdk/client-eks':
specifier: ^3.1076.0
version: 3.1076.0
@@ -70,6 +82,9 @@ importers:
'@aws-sdk/client-eventbridge':
specifier: ^3.1076.0
version: 3.1121.0
'@aws-sdk/client-glue':
specifier: ^3.1146.0
version: 3.1156.0
'@aws-sdk/client-iam':
specifier: ^3.1076.0
version: 3.1120.0
@@ -320,6 +335,18 @@ packages:
resolution: {integrity: sha512-CPVT+sEZLpYYLs6gIUr+O76QWzwjCIMp8ih7VZOQTzUhM8jXlDBwarupCMOkk830STVmKu5zhQjCzYI8i+XU3A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-appsync@3.1156.0':
resolution: {integrity: sha512-I3orpZmtgqcxOdMg5Eq6roEbzGnrbR46JwByagLaTfezCakqcJFeE6n4oboQv8EDcAH3/ypfelCsCof6Tngrcg==}
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':
resolution: {integrity: sha512-tZHW6n0vbA1fiJZtxCWVFWUssP47es/EAu7kH2FId6nY/svNoQVHYTqA7ALhLqB+W6ASzNTpnJeCq8evoXlKBQ==}
engines: {node: '>=20.0.0'}
@@ -368,6 +395,10 @@ packages:
resolution: {integrity: sha512-r3Kfyv+ax47DcXUKgMV/n9xUhKL1KjDBo3HC6+4CL+lOWXXYXsEJIWpQrDZYVwX8nbxuD7TXVFfEeY/38dmD1Q==}
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':
resolution: {integrity: sha512-Hqr3hdwUgqWAQbdx6UDy81UBF+jdu3ws6RrykQ4YmBhe/XOKv57lt2lhkjgBuF9fiSw4UUl2XQteks9oxgm4XA==}
engines: {node: '>=20.0.0'}
@@ -384,6 +415,10 @@ packages:
resolution: {integrity: sha512-+zT6z91ZxHozsKGWf5wOJfxEebht3GmPBoXedlWoumHeAvzAueM1KwbWkzClLWjpi76HXuCIx7R3x4pvSVGqIw==}
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':
resolution: {integrity: sha512-w1UNruAHPJ2l6+fNsW3wBYa725ALR1FRQe+rLheeziNK3EmGK6wxTm39dgKyehPmeSXxEQYCg5gcrYq3Ew3CzQ==}
engines: {node: '>=20.0.0'}
@@ -2898,15 +2933,15 @@ snapshots:
'@aws-crypto/util@5.2.0':
dependencies:
'@aws-sdk/types': 3.974.5
'@aws-sdk/types': 3.975.0
'@smithy/util-utf8': 2.3.0
tslib: 2.8.1
'@aws-sdk/checksums@3.1000.13':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
@@ -2955,6 +2990,39 @@ snapshots:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/client-appsync@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-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':
dependencies:
'@aws-sdk/core': 3.977.8
@@ -3095,6 +3163,17 @@ snapshots:
'@smithy/types': 4.19.0
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':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
@@ -3142,6 +3221,17 @@ snapshots:
'@smithy/types': 4.17.2
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':
dependencies:
'@aws-sdk/core': 3.977.9
@@ -3435,34 +3525,34 @@ snapshots:
'@aws-sdk/credential-provider-env@3.972.54':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.62':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.69':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.70':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.71':
@@ -3491,42 +3581,42 @@ snapshots:
'@aws-sdk/credential-provider-http@3.972.56':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/fetch-http-handler': 5.7.2
'@smithy/node-http-handler': 4.11.3
'@smithy/types': 4.17.2
'@aws-sdk/core': 3.978.5
'@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/credential-provider-http@3.972.64':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/fetch-http-handler': 5.7.2
'@smithy/node-http-handler': 4.11.3
'@smithy/types': 4.17.2
'@aws-sdk/core': 3.978.5
'@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/credential-provider-http@3.972.71':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/fetch-http-handler': 5.7.2
'@smithy/node-http-handler': 4.11.3
'@smithy/types': 4.17.2
'@aws-sdk/core': 3.978.5
'@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/credential-provider-http@3.972.72':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/fetch-http-handler': 5.7.2
'@smithy/node-http-handler': 4.11.3
'@smithy/types': 4.17.2
'@aws-sdk/core': 3.978.5
'@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/credential-provider-http@3.972.73':
@@ -3561,7 +3651,7 @@ snapshots:
'@aws-sdk/credential-provider-ini@3.972.61':
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-http': 3.972.73
'@aws-sdk/credential-provider-login': 3.972.60
@@ -3569,15 +3659,15 @@ snapshots:
'@aws-sdk/credential-provider-sso': 3.973.15
'@aws-sdk/credential-provider-web-identity': 3.972.77
'@aws-sdk/nested-clients': 3.997.28
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/credential-provider-imds': 4.5.0
'@smithy/types': 4.17.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.14':
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-http': 3.972.73
'@aws-sdk/credential-provider-login': 3.972.76
@@ -3585,15 +3675,15 @@ snapshots:
'@aws-sdk/credential-provider-sso': 3.973.15
'@aws-sdk/credential-provider-web-identity': 3.972.77
'@aws-sdk/nested-clients': 3.997.43
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/credential-provider-imds': 4.5.0
'@smithy/types': 4.17.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.15':
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-http': 3.972.73
'@aws-sdk/credential-provider-login': 3.972.77
@@ -3601,10 +3691,10 @@ snapshots:
'@aws-sdk/credential-provider-sso': 3.973.15
'@aws-sdk/credential-provider-web-identity': 3.972.77
'@aws-sdk/nested-clients': 3.997.44
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/credential-provider-imds': 4.5.0
'@smithy/types': 4.17.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.16':
@@ -3657,7 +3747,7 @@ snapshots:
'@aws-sdk/credential-provider-ini@3.973.7':
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-http': 3.972.64
'@aws-sdk/credential-provider-login': 3.972.69
@@ -3665,10 +3755,10 @@ snapshots:
'@aws-sdk/credential-provider-sso': 3.973.6
'@aws-sdk/credential-provider-web-identity': 3.972.68
'@aws-sdk/nested-clients': 3.997.36
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/credential-provider-imds': 4.4.14
'@smithy/types': 4.17.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.60':
@@ -3848,34 +3938,34 @@ snapshots:
'@aws-sdk/credential-provider-process@3.972.54':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.62':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.69':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.70':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.71':
@@ -3904,32 +3994,32 @@ snapshots:
'@aws-sdk/credential-provider-sso@3.972.60':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.28
'@aws-sdk/token-providers': 3.1080.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.13':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.43
'@aws-sdk/token-providers': 3.1111.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.14':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.44
'@aws-sdk/token-providers': 3.1116.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.15':
@@ -3964,48 +4054,48 @@ snapshots:
'@aws-sdk/credential-provider-sso@3.973.6':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.36
'@aws-sdk/token-providers': 3.1096.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.60':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.28
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.68':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.36
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.75':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.43
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.76':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.44
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.77':
@@ -4316,17 +4406,17 @@ snapshots:
'@aws-sdk/xml-builder@3.972.33':
dependencies:
'@smithy/types': 4.17.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.37':
dependencies:
'@smithy/types': 4.17.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.39':
dependencies:
'@smithy/types': 4.17.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.40':
@@ -4809,14 +4899,14 @@ snapshots:
'@smithy/credential-provider-imds@4.4.14':
dependencies:
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/credential-provider-imds@4.4.6':
dependencies:
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/credential-provider-imds@4.5.0':
@@ -4904,14 +4994,14 @@ snapshots:
'@smithy/signature-v4@5.6.10':
dependencies:
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/signature-v4@5.6.2':
dependencies:
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/signature-v4@5.7.0':