Merge pull request 'feat(api): add EFS file systems to the cloud explorer' (#27) from feat/efs into main
CI / checks (push) Has been cancelled

Reviewed-on: #27
This commit was merged in pull request #27.
This commit is contained in:
2026-10-25 23:44:41 +00:00
23 changed files with 1304 additions and 1 deletions
+1
View File
@@ -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 |
+1
View File
@@ -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
}
+3
View File
@@ -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',
+25
View File
@@ -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>
+20 -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 {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 ?? []),
+2
View File
@@ -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(),
+29
View File
@@ -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', {
+47
View File
@@ -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`)
+50
View File
@@ -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&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>
}
@@ -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,
+6
View File
@@ -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; }
+14
View File
@@ -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
+2
View File
@@ -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[]
+18
View File
@@ -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