Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26b635db0b | |||
| efaa2e6826 | |||
| 48114da772 |
@@ -70,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 |
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -37,6 +37,7 @@ 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";
|
||||
|
||||
const endpoint = process.env.FLOCI_ENDPOINT;
|
||||
const region = process.env.AWS_REGION || "us-east-1";
|
||||
@@ -105,6 +106,7 @@ export type AwsClients = {
|
||||
autoscaling: AutoScalingClient;
|
||||
acm: ACMClient;
|
||||
pipes: PipesClient;
|
||||
efs: EFSClient;
|
||||
};
|
||||
|
||||
export type AwsClientName = keyof AwsClients;
|
||||
@@ -157,6 +159,7 @@ function buildClients(accountId: string): AwsClients {
|
||||
autoscaling: new AutoScalingClient(base),
|
||||
acm: new ACMClient(base),
|
||||
pipes: new PipesClient(base),
|
||||
efs: new EFSClient(base),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -132,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',
|
||||
|
||||
@@ -188,6 +188,7 @@ export type UserPoolActionName =
|
||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
|
||||
export type CertificateActionName = 'download' | 'export' | 'revoke'
|
||||
export type FileSystemActionName = 'createMountTarget' | 'deleteMountTarget' | 'createAccessPoint' | 'deleteAccessPoint' | 'attachFunction' | 'detachFunction'
|
||||
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
||||
export type HttpApiActionName =
|
||||
| 'describe'
|
||||
@@ -249,6 +250,7 @@ export interface ServiceSchema {
|
||||
distributionActions?: CapabilitySchema<DistributionActionName>[]
|
||||
autoScalingActions?: CapabilitySchema<AutoScalingActionName>[]
|
||||
certificateActions?: CapabilitySchema<CertificateActionName>[]
|
||||
fileSystemActions?: CapabilitySchema<FileSystemActionName>[]
|
||||
monitoringActions?: CapabilitySchema<MonitoringActionName>[]
|
||||
/** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */
|
||||
httpApiActions?: CapabilitySchema<HttpApiActionName>[]
|
||||
@@ -546,6 +548,22 @@ export interface AutoScalingCapacityInput {
|
||||
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 FileSystemAttachmentInput {
|
||||
accessPointId: string
|
||||
functionName: string
|
||||
mountPath: string
|
||||
}
|
||||
|
||||
export interface DistributionInvalidation {
|
||||
id: string
|
||||
status: string
|
||||
@@ -1217,6 +1235,13 @@ export interface CloudServiceAdapter {
|
||||
/** 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>
|
||||
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>
|
||||
|
||||
@@ -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 {AutoScalingActionName, CloudProvider, CloudServiceAdapter, CloudServiceType, CertificateActionName, DatabaseActionName, DistributionActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
||||
import type {AutoScalingActionName, CloudProvider, CloudServiceAdapter, CloudServiceType, CertificateActionName, DatabaseActionName, DistributionActionName, FileSystemActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
||||
|
||||
/**
|
||||
* Guards the schema-to-implementation contract for every registered adapter.
|
||||
@@ -81,6 +81,15 @@ const METHOD_FOR_CERTIFICATE_ACTION: Record<CertificateActionName, keyof CloudSe
|
||||
revoke: 'revokeCertificate',
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -217,6 +226,15 @@ describe('registered adapters honour their schema', () => {
|
||||
).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]
|
||||
@@ -240,6 +258,7 @@ describe('registered adapters honour their schema', () => {
|
||||
...(adapter.schema().capabilities?.userPoolActions ?? []),
|
||||
...(adapter.schema().capabilities?.distributionActions ?? []),
|
||||
...(adapter.schema().capabilities?.autoScalingActions ?? []),
|
||||
...(adapter.schema().capabilities?.fileSystemActions ?? []),
|
||||
...(adapter.schema().capabilities?.certificateActions ?? []),
|
||||
...(adapter.schema().capabilities?.monitoringActions ?? []),
|
||||
...(adapter.schema().capabilities?.httpApiActions ?? []),
|
||||
|
||||
@@ -53,6 +53,7 @@ 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 {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
|
||||
import {awsClientsForAccount, resolveAccountId} from './aws'
|
||||
import {createEc2Service} from './services/ec2'
|
||||
@@ -109,6 +110,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
||||
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 AwsMonitoringAdapter(clients.cloudwatch),
|
||||
new AzureStorageAdapter(),
|
||||
new AzureServiceBusAdapter(),
|
||||
|
||||
@@ -19,6 +19,7 @@ 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 {awsCertificatesSchema} from '../cloud-spi/certificatesSchema'
|
||||
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
|
||||
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
||||
@@ -875,6 +876,34 @@ describe('cloud schema routes', () => {
|
||||
expect(calls).toEqual([['capacity', 'web-asg', {desiredCapacity: 3}], ['terminate', 'web-asg', 'i-abc', true]])
|
||||
})
|
||||
|
||||
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', {
|
||||
|
||||
@@ -3,6 +3,8 @@ import type {Context} from 'hono'
|
||||
import type {
|
||||
AlarmStateValue,
|
||||
AutoScalingCapacityInput,
|
||||
AccessPointInput,
|
||||
FileSystemAttachmentInput,
|
||||
CertificateRevocationReason,
|
||||
CloudProvider,
|
||||
CloudServiceType,
|
||||
@@ -554,6 +556,51 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
@@ -94,6 +94,8 @@ import type {
|
||||
UserPoolClientSettings,
|
||||
DistributionInvalidation,
|
||||
AutoScalingCapacityInput,
|
||||
AccessPointInput,
|
||||
FileSystemAttachmentInput,
|
||||
CertificatePem,
|
||||
CertificateRevocationReason,
|
||||
ExportedCertificate,
|
||||
@@ -964,6 +966,42 @@ export class CloudProxyService {
|
||||
return adapter.revokeCertificate(certificateId, reason)
|
||||
}
|
||||
|
||||
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`)
|
||||
|
||||
@@ -155,6 +155,14 @@ export const apiEndpointKeys = {
|
||||
export: "clouds.services.certificates.export",
|
||||
revoke: "clouds.services.certificates.revoke",
|
||||
},
|
||||
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",
|
||||
@@ -956,6 +964,48 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[
|
||||
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,
|
||||
{
|
||||
|
||||
@@ -34,6 +34,8 @@ import type {
|
||||
UserPoolClientSettings,
|
||||
DistributionInvalidation,
|
||||
AutoScalingCapacityInput,
|
||||
AccessPointInput,
|
||||
FileSystemAttachmentInput,
|
||||
CertificatePem,
|
||||
CertificateRevocationReason,
|
||||
ExportedCertificate,
|
||||
@@ -1721,6 +1723,84 @@ export async function revokeCertificate(
|
||||
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,
|
||||
|
||||
@@ -69,6 +69,7 @@ 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 { CertificatePanel } from "@/components/CertificatePanel";
|
||||
import { PipePanel } from "@/components/PipePanel";
|
||||
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
|
||||
@@ -999,6 +1000,9 @@ export function DynamicResourceView({
|
||||
{service === "autoscaling" && cloud === "aws" && (
|
||||
<AutoScalingPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "filesystems" && cloud === "aws" && (
|
||||
<FileSystemPanel 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'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>
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Database,
|
||||
Earth,
|
||||
FileBadge,
|
||||
FolderTree,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Hammer,
|
||||
@@ -39,6 +40,7 @@ import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/
|
||||
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 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'
|
||||
@@ -101,6 +103,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
||||
dns: Globe,
|
||||
cdn: Earth,
|
||||
autoscaling: Scaling,
|
||||
filesystems: FolderTree,
|
||||
certificates: FileBadge,
|
||||
monitoring: Activity,
|
||||
loadbalancing: Scale,
|
||||
@@ -151,6 +154,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
||||
dns: route53Icon,
|
||||
cdn: cloudFrontIcon,
|
||||
autoscaling: autoScalingIcon,
|
||||
filesystems: efsIcon,
|
||||
certificates: certificateManagerIcon,
|
||||
monitoring: cloudWatchIcon,
|
||||
workflows: stepFunctionsIcon,
|
||||
|
||||
@@ -6630,3 +6630,9 @@ tr[aria-current="true"] td { background: var(--hover-md); }
|
||||
.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; }
|
||||
|
||||
@@ -362,6 +362,20 @@ export interface AutoScalingCapacityInput {
|
||||
maxSize?: number
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -30,6 +30,7 @@ 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 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'
|
||||
@@ -95,6 +96,7 @@ export interface ServiceSchema {
|
||||
distributionActions?: Array<CapabilitySchema<DistributionActionName>>
|
||||
autoScalingActions?: Array<CapabilitySchema<AutoScalingActionName>>
|
||||
certificateActions?: Array<CapabilitySchema<CertificateActionName>>
|
||||
fileSystemActions?: Array<CapabilitySchema<FileSystemActionName>>
|
||||
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
|
||||
}
|
||||
filters: FieldSchema[]
|
||||
|
||||
Generated
+18
@@ -61,6 +61,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
|
||||
@@ -375,6 +378,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'}
|
||||
@@ -3113,6 +3120,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
|
||||
|
||||
Reference in New Issue
Block a user