diff --git a/README.md b/README.md index aa09c3c..9b0d753 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/packages/api/package.json b/packages/api/package.json index dbec764..d2ed3ab 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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", diff --git a/packages/api/src/adapter-aws/AwsFileSystemsAdapter.test.ts b/packages/api/src/adapter-aws/AwsFileSystemsAdapter.test.ts new file mode 100644 index 0000000..1be7e0c --- /dev/null +++ b/packages/api/src/adapter-aws/AwsFileSystemsAdapter.test.ts @@ -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 + +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: []}) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsFileSystemsAdapter.ts b/packages/api/src/adapter-aws/AwsFileSystemsAdapter.ts new file mode 100644 index 0000000..9fd0aa4 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsFileSystemsAdapter.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.lambda.send(new UpdateFunctionConfigurationCommand({FunctionName: functionName, FileSystemConfigs: []})) + return this.detail(fileSystemId) + } + + private async describe(id: string): Promise { + 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 { + if (!await this.describe(id)) throw new NotFoundError(`File system ${id} not found`) + } + + private async detail(id: string): Promise { + const detail = await this.get(id) + if (!detail) throw new NotFoundError(`File system ${id} not found`) + return detail + } + + private async mountTargets(fileSystemId: string): Promise { + return (await this.efs.send(new DescribeMountTargetsCommand({FileSystemId: fileSystemId}))).MountTargets ?? [] + } + + private async accessPoints(fileSystemId: string): Promise { + 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 { + 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 { + const res = await this.ec2.send(new DescribeSubnetsCommand({Filters: [{Name: 'default-for-az', Values: ['true']}]})) + const byZone = new Map() + 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(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 +} diff --git a/packages/api/src/aws.ts b/packages/api/src/aws.ts index ced2124..5ca46c1 100644 --- a/packages/api/src/aws.ts +++ b/packages/api/src/aws.ts @@ -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), }; } diff --git a/packages/api/src/cloud-spi/fileSystemsSchema.ts b/packages/api/src/cloud-spi/fileSystemsSchema.ts new file mode 100644 index 0000000..e81fd78 --- /dev/null +++ b/packages/api/src/cloud-spi/fileSystemsSchema.ts @@ -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 +} diff --git a/packages/api/src/cloud-spi/serviceCatalog.ts b/packages/api/src/cloud-spi/serviceCatalog.ts index 21d5b9a..616759d 100644 --- a/packages/api/src/cloud-spi/serviceCatalog.ts +++ b/packages/api/src/cloud-spi/serviceCatalog.ts @@ -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', diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index 72208f6..a4e2de0 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -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[] autoScalingActions?: CapabilitySchema[] certificateActions?: CapabilitySchema[] + fileSystemActions?: CapabilitySchema[] monitoringActions?: CapabilitySchema[] /** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */ httpApiActions?: CapabilitySchema[] @@ -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 getCertificatePem?(certificateId: string): Promise + createMountTarget?(fileSystemId: string, subnetId: string): Promise + deleteMountTarget?(fileSystemId: string, mountTargetId: string): Promise + createAccessPoint?(fileSystemId: string, input: AccessPointInput): Promise + deleteAccessPoint?(fileSystemId: string, accessPointId: string): Promise + /** Mounts one of the file system's access points into a Lambda function, as UpdateFunctionConfiguration does. */ + attachFileSystemToFunction?(fileSystemId: string, input: FileSystemAttachmentInput): Promise + detachFileSystemFromFunction?(fileSystemId: string, functionName: string): Promise exportCertificate?(certificateId: string, passphrase: string): Promise revokeCertificate?(certificateId: string, reason: CertificateRevocationReason): Promise updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise diff --git a/packages/api/src/cloudProxy.test.ts b/packages/api/src/cloudProxy.test.ts index adc7043..58a4b6d 100644 --- a/packages/api/src/cloudProxy.test.ts +++ b/packages/api/src/cloudProxy.test.ts @@ -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 = { + createMountTarget: 'createMountTarget', + deleteMountTarget: 'deleteMountTarget', + createAccessPoint: 'createAccessPoint', + deleteAccessPoint: 'deleteAccessPoint', + attachFunction: 'attachFileSystemToFunction', + detachFunction: 'detachFileSystemFromFunction', +} + const METHOD_FOR_AUTO_SCALING_ACTION: Record = { 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 ?? []), diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index 96e85af..925c261 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -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(), diff --git a/packages/api/src/routes/clouds.test.ts b/packages/api/src/routes/clouds.test.ts index 636500b..546b58a 100644 --- a/packages/api/src/routes/clouds.test.ts +++ b/packages/api/src/routes/clouds.test.ts @@ -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', { diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index 6eb6a31..f879e5d 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -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()), 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()))) + }) + + 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) diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index 21ee5a6..2c7289e 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { const adapter = this.requireAdapter(cloud, 'autoscaling') if (!adapter.setAutoScalingCapacity) throw new NotSupportedError(`Changing capacity is not supported for ${cloud}/autoscaling`) diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index 65678d0..60f70ed 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -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, { diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 178a278..efbeab7 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -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 { + const res = await apiClient.call( + 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 { + const res = await apiClient.call( + 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 { + const res = await apiClient.call( + 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 { + const res = await apiClient.call( + 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 { + const res = await apiClient.call( + 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 { + const res = await apiClient.call( + apiEndpointKeys.clouds.filesystems.function, + requestOptions(cloud, "filesystems"), + { cloud, id: fileSystemId, functionName }, + ); + return res.data; +} + export async function setAutoScalingCapacity( cloud: CloudProvider, groupName: string, diff --git a/packages/frontend/src/components/DynamicResourceView.tsx b/packages/frontend/src/components/DynamicResourceView.tsx index 8f2560f..fb8926c 100644 --- a/packages/frontend/src/components/DynamicResourceView.tsx +++ b/packages/frontend/src/components/DynamicResourceView.tsx @@ -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" && ( )} + {service === "filesystems" && cloud === "aws" && ( + + )} {service === "cdn" && cloud === "aws" && ( )} diff --git a/packages/frontend/src/components/FileSystemPanel.test.tsx b/packages/frontend/src/components/FileSystemPanel.test.tsx new file mode 100644 index 0000000..72e0bb2 --- /dev/null +++ b/packages/frontend/src/components/FileSystemPanel.test.tsx @@ -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 = {}): 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() +} + +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() +}) diff --git a/packages/frontend/src/components/FileSystemPanel.tsx b/packages/frontend/src/components/FileSystemPanel.tsx new file mode 100644 index 0000000..1c20fd8 --- /dev/null +++ b/packages/frontend/src/components/FileSystemPanel.tsx @@ -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

Select a file system

Choose a file system to manage its mount targets and access points, and attach it to Lambda functions.

+ } + + 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
+
+

EFS file system

{String(metadata.fileSystemId ?? fileSystem.id)} · {String(metadata.throughputMode ?? '')} · {metadata.encrypted ? 'encrypted' : 'not encrypted'}
+
+ +
+
+ +

Mount targets

Functions and tasks reach the file system through a mount target in their subnet.
+ {mountTargets.length === 0 ?

No mount targets yet. Add one before attaching the file system to a function.

+ :
{mountTargets.map((mountTarget) => ( + + + + + + + + + ))}
Mount targetSubnetZoneIP addressState
{mountTarget.id}{mountTarget.subnetId ?? '—'}{mountTarget.availabilityZone ?? '—'}{mountTarget.ipAddress ?? '—'}{mountTarget.state ?? '—'}
} +
{ event.preventDefault(); run(() => addMountTarget.mutate()) }}> + +
+
+ +

Access points

An entry point into one directory, as a fixed POSIX user. Lambda mounts a file system through an access point.
+ {accessPoints.length === 0 ?

No access points yet.

+ :
{accessPoints.map((accessPoint) => ( + + + + + + + + ))}
Access pointRoot directoryPOSIX userState
{accessPoint.name && <>{accessPoint.name}
}{accessPoint.id}
{accessPoint.path}{accessPoint.permissions ? ({accessPoint.permissions}) : null}{accessPoint.uid ?? '—'}:{accessPoint.gid ?? '—'}{accessPoint.state ?? '—'}
} +
{ event.preventDefault(); run(() => addAccessPoint.mutate()) }}> + + + + + +
+
+ +

Lambda functions

Functions that mount this file system share its files. A function can mount one file system.
+ {mounted.length === 0 ?

No function mounts this file system.

+ :
{mounted.map((fn) => ( + + + + + + + ))}
FunctionMount pathAccess point
{fn.name}{fn.mountPath ?? '—'}{accessPointLabel(fn.accessPointId)}
} + {accessPoints.length > 0 &&
{ event.preventDefault(); run(() => attach.mutate()) }}> + + + +
+
} + {accessPoints.length > 0 &&

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.

} + + {notice &&

{notice}

} + {error instanceof Error &&

{error.message}

} +
+} diff --git a/packages/frontend/src/components/serviceIcons.tsx b/packages/frontend/src/components/serviceIcons.tsx index e3457f9..993b91d 100644 --- a/packages/frontend/src/components/serviceIcons.tsx +++ b/packages/frontend/src/components/serviceIcons.tsx @@ -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 = { dns: Globe, cdn: Earth, autoscaling: Scaling, + filesystems: FolderTree, certificates: FileBadge, monitoring: Activity, loadbalancing: Scale, @@ -151,6 +154,7 @@ const AWS_SERVICE_ICON_ASSETS: Record = { dns: route53Icon, cdn: cloudFrontIcon, autoscaling: autoScalingIcon, + filesystems: efsIcon, certificates: certificateManagerIcon, monitoring: cloudWatchIcon, workflows: stepFunctionsIcon, diff --git a/packages/frontend/src/index.css b/packages/frontend/src/index.css index 2361011..b2a128a 100644 --- a/packages/frontend/src/index.css +++ b/packages/frontend/src/index.css @@ -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; } diff --git a/packages/frontend/src/types/resource.ts b/packages/frontend/src/types/resource.ts index a396a30..bb15b1f 100644 --- a/packages/frontend/src/types/resource.ts +++ b/packages/frontend/src/types/resource.ts @@ -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 diff --git a/packages/frontend/src/types/schema.ts b/packages/frontend/src/types/schema.ts index f8ea8af..365238f 100644 --- a/packages/frontend/src/types/schema.ts +++ b/packages/frontend/src/types/schema.ts @@ -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> autoScalingActions?: Array> certificateActions?: Array> + fileSystemActions?: Array> monitoringActions?: Array> } filters: FieldSchema[] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4533069..9c491a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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