feat(api): add EFS file systems to the cloud explorer #27

Merged
Zakaria merged 1 commits from feat/efs into main 2026-10-25 23:44:43 +00:00
23 changed files with 1304 additions and 1 deletions
Showing only changes of commit 26b635db0b - Show all commits
+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 | | Developer Tools | CodeDeploy | Yes (list, create, delete, inspect) | No | No |
| Storage | Storage | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | | Storage | Storage | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) |
| Storage | ECR / Elastic Container Registry | Yes (list, create, delete, inspect) | No | No | | Storage | ECR / Elastic Container Registry | Yes (list, create, delete, inspect) | No | No |
| Storage | EFS / File Systems | Yes (list, create, delete, inspect) | No | No |
| Databases | Database | Yes (list, create, update, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | | Databases | Database | Yes (list, create, update, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
| Databases | DynamoDB / Cosmos DB NoSQL / NoSQL | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | No | | Databases | DynamoDB / Cosmos DB NoSQL / NoSQL | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | No |
| Databases | ElastiCache | Yes (list, create, delete, inspect) | No | No | | Databases | ElastiCache | Yes (list, create, delete, inspect) | No | No |
+1
View File
@@ -27,6 +27,7 @@
"@aws-sdk/client-ec2": "^3.1076.0", "@aws-sdk/client-ec2": "^3.1076.0",
"@aws-sdk/client-ecr": "^3.1076.0", "@aws-sdk/client-ecr": "^3.1076.0",
"@aws-sdk/client-ecs": "^3.1076.0", "@aws-sdk/client-ecs": "^3.1076.0",
"@aws-sdk/client-efs": "^3.1146.0",
"@aws-sdk/client-eks": "^3.1076.0", "@aws-sdk/client-eks": "^3.1076.0",
"@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0", "@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0",
"@aws-sdk/client-elasticache": "^3.1146.0", "@aws-sdk/client-elasticache": "^3.1146.0",
@@ -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 { AutoScalingClient } from "@aws-sdk/client-auto-scaling";
import { ACMClient } from "@aws-sdk/client-acm"; import { ACMClient } from "@aws-sdk/client-acm";
import { PipesClient } from "@aws-sdk/client-pipes"; import { PipesClient } from "@aws-sdk/client-pipes";
import { EFSClient } from "@aws-sdk/client-efs";
const endpoint = process.env.FLOCI_ENDPOINT; const endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1"; const region = process.env.AWS_REGION || "us-east-1";
@@ -105,6 +106,7 @@ export type AwsClients = {
autoscaling: AutoScalingClient; autoscaling: AutoScalingClient;
acm: ACMClient; acm: ACMClient;
pipes: PipesClient; pipes: PipesClient;
efs: EFSClient;
}; };
export type AwsClientName = keyof AwsClients; export type AwsClientName = keyof AwsClients;
@@ -157,6 +159,7 @@ function buildClients(accountId: string): AwsClients {
autoscaling: new AutoScalingClient(base), autoscaling: new AutoScalingClient(base),
acm: new ACMClient(base), acm: new ACMClient(base),
pipes: new PipesClient(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', group: 'Storage',
order: 20, order: 20,
}, },
filesystems: {
displayName: 'File Systems',
displayNameByCloud: {aws: 'EFS'},
iconKey: 'filesystems',
group: 'Storage',
order: 30,
},
database: {displayName: 'Database', iconKey: 'database', group: 'Databases', order: 10}, database: {displayName: 'Database', iconKey: 'database', group: 'Databases', order: 10},
nosql: { nosql: {
displayName: 'NoSQL', displayName: 'NoSQL',
+25
View File
@@ -188,6 +188,7 @@ export type UserPoolActionName =
export type DistributionActionName = 'setEnabled' | 'createInvalidation' export type DistributionActionName = 'setEnabled' | 'createInvalidation'
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance' export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
export type CertificateActionName = 'download' | 'export' | 'revoke' 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 MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
export type HttpApiActionName = export type HttpApiActionName =
| 'describe' | 'describe'
@@ -249,6 +250,7 @@ export interface ServiceSchema {
distributionActions?: CapabilitySchema<DistributionActionName>[] distributionActions?: CapabilitySchema<DistributionActionName>[]
autoScalingActions?: CapabilitySchema<AutoScalingActionName>[] autoScalingActions?: CapabilitySchema<AutoScalingActionName>[]
certificateActions?: CapabilitySchema<CertificateActionName>[] certificateActions?: CapabilitySchema<CertificateActionName>[]
fileSystemActions?: CapabilitySchema<FileSystemActionName>[]
monitoringActions?: CapabilitySchema<MonitoringActionName>[] monitoringActions?: CapabilitySchema<MonitoringActionName>[]
/** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */ /** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */
httpApiActions?: CapabilitySchema<HttpApiActionName>[] httpApiActions?: CapabilitySchema<HttpApiActionName>[]
@@ -546,6 +548,22 @@ export interface AutoScalingCapacityInput {
maxSize?: number maxSize?: number
} }
export interface AccessPointInput {
name?: string
/** Where the access point's root sits inside the file system; created on first use. */
path: string
uid: number
gid: number
/** Octal permissions for the root directory when EFS creates it, such as 755. */
permissions: string
}
export interface FileSystemAttachmentInput {
accessPointId: string
functionName: string
mountPath: string
}
export interface DistributionInvalidation { export interface DistributionInvalidation {
id: string id: string
status: 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. */ /** 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> terminateAutoScalingInstance?(groupName: string, instanceId: string, replace: boolean): Promise<void>
getCertificatePem?(certificateId: string): Promise<CertificatePem> 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> exportCertificate?(certificateId: string, passphrase: string): Promise<ExportedCertificate>
revokeCertificate?(certificateId: string, reason: CertificateRevocationReason): Promise<CloudResource> revokeCertificate?(certificateId: string, reason: CertificateRevocationReason): Promise<CloudResource>
updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings> updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings>
+20 -1
View File
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
import {createCloudAdapterRegistry} from './cloudProxy' import {createCloudAdapterRegistry} from './cloudProxy'
import {checkChildCapabilities} from './cloud-spi/childCapabilities' import {checkChildCapabilities} from './cloud-spi/childCapabilities'
import {isServiceType} from './cloud-spi/serviceCatalog' import {isServiceType} from './cloud-spi/serviceCatalog'
import type {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. * 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', 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> = { const METHOD_FOR_AUTO_SCALING_ACTION: Record<AutoScalingActionName, keyof CloudServiceAdapter> = {
setCapacity: 'setAutoScalingCapacity', setCapacity: 'setAutoScalingCapacity',
terminateInstance: 'terminateAutoScalingInstance', terminateInstance: 'terminateAutoScalingInstance',
@@ -217,6 +226,15 @@ describe('registered adapters honour their schema', () => {
).toBe('function') ).toBe('function')
} }
const fileSystemClaims = (adapter.schema().capabilities?.fileSystemActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of fileSystemClaims) {
const method = METHOD_FOR_FILE_SYSTEM_ACTION[capability.name]
expect(
typeof adapter[method],
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
).toBe('function')
}
const distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available') const distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of distributionClaims) { for (const capability of distributionClaims) {
const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name] const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name]
@@ -240,6 +258,7 @@ describe('registered adapters honour their schema', () => {
...(adapter.schema().capabilities?.userPoolActions ?? []), ...(adapter.schema().capabilities?.userPoolActions ?? []),
...(adapter.schema().capabilities?.distributionActions ?? []), ...(adapter.schema().capabilities?.distributionActions ?? []),
...(adapter.schema().capabilities?.autoScalingActions ?? []), ...(adapter.schema().capabilities?.autoScalingActions ?? []),
...(adapter.schema().capabilities?.fileSystemActions ?? []),
...(adapter.schema().capabilities?.certificateActions ?? []), ...(adapter.schema().capabilities?.certificateActions ?? []),
...(adapter.schema().capabilities?.monitoringActions ?? []), ...(adapter.schema().capabilities?.monitoringActions ?? []),
...(adapter.schema().capabilities?.httpApiActions ?? []), ...(adapter.schema().capabilities?.httpApiActions ?? []),
+2
View File
@@ -53,6 +53,7 @@ import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter'
import {AwsAutoScalingAdapter} from './adapter-aws/AwsAutoScalingAdapter' import {AwsAutoScalingAdapter} from './adapter-aws/AwsAutoScalingAdapter'
import {AwsCertificatesAdapter} from './adapter-aws/AwsCertificatesAdapter' import {AwsCertificatesAdapter} from './adapter-aws/AwsCertificatesAdapter'
import {AwsPipesAdapter} from './adapter-aws/AwsPipesAdapter' import {AwsPipesAdapter} from './adapter-aws/AwsPipesAdapter'
import {AwsFileSystemsAdapter} from './adapter-aws/AwsFileSystemsAdapter'
import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter' import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws' import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2' import {createEc2Service} from './services/ec2'
@@ -109,6 +110,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsAutoScalingAdapter(clients.autoscaling, clients.ec2), new AwsAutoScalingAdapter(clients.autoscaling, clients.ec2),
new AwsCertificatesAdapter(clients.acm, resolveAccountId(accountId)), new AwsCertificatesAdapter(clients.acm, resolveAccountId(accountId)),
new AwsPipesAdapter(clients.pipes), new AwsPipesAdapter(clients.pipes),
new AwsFileSystemsAdapter(clients.efs, clients.lambda, clients.ec2),
new AwsMonitoringAdapter(clients.cloudwatch), new AwsMonitoringAdapter(clients.cloudwatch),
new AzureStorageAdapter(), new AzureStorageAdapter(),
new AzureServiceBusAdapter(), new AzureServiceBusAdapter(),
+29
View File
@@ -19,6 +19,7 @@ import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema' import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
import {awsCdnSchema} from '../cloud-spi/cdnSchema' import {awsCdnSchema} from '../cloud-spi/cdnSchema'
import {awsAutoScalingSchema} from '../cloud-spi/autoScalingSchema' import {awsAutoScalingSchema} from '../cloud-spi/autoScalingSchema'
import {awsFileSystemsSchema} from '../cloud-spi/fileSystemsSchema'
import {awsCertificatesSchema} from '../cloud-spi/certificatesSchema' import {awsCertificatesSchema} from '../cloud-spi/certificatesSchema'
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema' import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema' import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
@@ -875,6 +876,34 @@ describe('cloud schema routes', () => {
expect(calls).toEqual([['capacity', 'web-asg', {desiredCapacity: 3}], ['terminate', 'web-asg', 'i-abc', true]]) 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 () => { test('clears the SES mailbox through the email adapter', async () => {
let cleared = false let cleared = false
const app = appWithRoutes([mockAdapter('aws', { const app = appWithRoutes([mockAdapter('aws', {
+47
View File
@@ -3,6 +3,8 @@ import type {Context} from 'hono'
import type { import type {
AlarmStateValue, AlarmStateValue,
AutoScalingCapacityInput, AutoScalingCapacityInput,
AccessPointInput,
FileSystemAttachmentInput,
CertificateRevocationReason, CertificateRevocationReason,
CloudProvider, CloudProvider,
CloudServiceType, 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) => { app.put('/:cloud/services/autoscaling/resources/:id/capacity', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
@@ -94,6 +94,8 @@ import type {
UserPoolClientSettings, UserPoolClientSettings,
DistributionInvalidation, DistributionInvalidation,
AutoScalingCapacityInput, AutoScalingCapacityInput,
AccessPointInput,
FileSystemAttachmentInput,
CertificatePem, CertificatePem,
CertificateRevocationReason, CertificateRevocationReason,
ExportedCertificate, ExportedCertificate,
@@ -964,6 +966,42 @@ export class CloudProxyService {
return adapter.revokeCertificate(certificateId, reason) 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> { async setAutoScalingCapacity(cloud: CloudProvider, groupName: string, input: AutoScalingCapacityInput): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'autoscaling') const adapter = this.requireAdapter(cloud, 'autoscaling')
if (!adapter.setAutoScalingCapacity) throw new NotSupportedError(`Changing capacity is not supported for ${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", export: "clouds.services.certificates.export",
revoke: "clouds.services.certificates.revoke", 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: { autoscaling: {
capacity: "clouds.services.autoscaling.capacity", capacity: "clouds.services.autoscaling.capacity",
terminate: "clouds.services.autoscaling.instances.terminate", terminate: "clouds.services.autoscaling.instances.terminate",
@@ -956,6 +964,48 @@ export const endpointRegistry: EndpointRegistry = new Map([
method: "POST", 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, apiEndpointKeys.clouds.autoscaling.capacity,
{ {
@@ -34,6 +34,8 @@ import type {
UserPoolClientSettings, UserPoolClientSettings,
DistributionInvalidation, DistributionInvalidation,
AutoScalingCapacityInput, AutoScalingCapacityInput,
AccessPointInput,
FileSystemAttachmentInput,
CertificatePem, CertificatePem,
CertificateRevocationReason, CertificateRevocationReason,
ExportedCertificate, ExportedCertificate,
@@ -1721,6 +1723,84 @@ export async function revokeCertificate(
return res.data; 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( export async function setAutoScalingCapacity(
cloud: CloudProvider, cloud: CloudProvider,
groupName: string, groupName: string,
@@ -69,6 +69,7 @@ import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel"; import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
import { CloudFrontPanel } from "@/components/CloudFrontPanel"; import { CloudFrontPanel } from "@/components/CloudFrontPanel";
import { AutoScalingPanel } from "@/components/AutoScalingPanel"; import { AutoScalingPanel } from "@/components/AutoScalingPanel";
import { FileSystemPanel } from "@/components/FileSystemPanel";
import { CertificatePanel } from "@/components/CertificatePanel"; import { CertificatePanel } from "@/components/CertificatePanel";
import { PipePanel } from "@/components/PipePanel"; import { PipePanel } from "@/components/PipePanel";
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel"; import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
@@ -999,6 +1000,9 @@ export function DynamicResourceView({
{service === "autoscaling" && cloud === "aws" && ( {service === "autoscaling" && cloud === "aws" && (
<AutoScalingPanel resource={activeSelected} runtimeReachable={canUseRuntime} /> <AutoScalingPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)} )}
{service === "filesystems" && cloud === "aws" && (
<FileSystemPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "cdn" && cloud === "aws" && ( {service === "cdn" && cloud === "aws" && (
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} /> <CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)} )}
@@ -0,0 +1,104 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {MemoryRouter} from 'react-router-dom'
import {beforeEach, expect, test, vi} from 'vitest'
import {FileSystemPanel} from './FileSystemPanel'
import type {CloudResource} from '@/types/resource'
const api = vi.hoisted(() => ({
getCloudResource: vi.fn(),
listCloudResources: vi.fn(),
createMountTarget: vi.fn(),
deleteMountTarget: vi.fn(),
createAccessPoint: vi.fn(),
deleteAccessPoint: vi.fn(),
attachFileSystemToFunction: vi.fn(),
detachFileSystemFromFunction: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
function fileSystem(metadata: Record<string, unknown> = {}): CloudResource {
return {
id: 'fs-1', name: 'shared', cloud: 'aws', service: 'filesystems', type: 'fileSystem', region: 'us-east-1', createdAt: null, status: 'available',
metadata: {
fileSystemId: 'fs-1', throughputMode: 'bursting', encrypted: true,
mountTargets: [{id: 'fsmt-1', subnetId: 'subnet-a', availabilityZone: 'us-east-1a', ipAddress: '10.0.0.10', state: 'available'}],
accessPoints: [{id: 'fsap-1', arn: 'arn:aws:elasticfilesystem:us-east-1:000000000000:access-point/fsap-1', name: 'app', path: '/app', uid: 1000, gid: 1000, permissions: '755', state: 'available'}],
functions: [{name: 'writer', accessPointId: 'fsap-1', mountPath: '/mnt/share'}],
...metadata,
},
}
}
function renderPanel(resource: CloudResource) {
api.getCloudResource.mockResolvedValue(resource)
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<MemoryRouter><QueryClientProvider client={client}><FileSystemPanel resource={resource} runtimeReachable/></QueryClientProvider></MemoryRouter>)
}
beforeEach(() => {
vi.clearAllMocks()
api.listCloudResources.mockResolvedValue([{id: 'reader', name: 'reader'}, {id: 'writer', name: 'writer'}])
for (const fn of [api.createMountTarget, api.deleteMountTarget, api.createAccessPoint, api.deleteAccessPoint, api.attachFileSystemToFunction, api.detachFileSystemFromFunction]) {
fn.mockResolvedValue(fileSystem())
}
})
test('shows mount targets, access points and the functions that mount the file system', async () => {
renderPanel(fileSystem())
expect(await screen.findByText('fsmt-1')).toBeInTheDocument()
expect(screen.getByText('fsap-1')).toBeInTheDocument()
expect(screen.getByRole('link', {name: 'writer'})).toHaveAttribute('href', '/cloud-explorer/aws/serverless')
expect(screen.getByText('/mnt/share')).toBeInTheDocument()
})
test('adds a mount target and creates an access point', async () => {
const user = userEvent.setup()
renderPanel(fileSystem())
const mountForm = await screen.findByRole('form', {name: 'Add mount target'})
await user.type(within(mountForm).getByLabelText('Subnet ID'), 'subnet-b')
await user.click(within(mountForm).getByRole('button', {name: 'Add mount target'}))
expect(api.createMountTarget).toHaveBeenCalledWith('aws', 'fs-1', 'subnet-b')
expect(await screen.findByText('Added a mount target in subnet-b.')).toBeInTheDocument()
const accessPointForm = screen.getByRole('form', {name: 'Create access point'})
const path = within(accessPointForm).getByLabelText('Root directory')
await user.clear(path)
await user.type(path, '/uploads')
await user.click(within(accessPointForm).getByRole('button', {name: 'Create access point'}))
expect(api.createAccessPoint).toHaveBeenCalledWith('aws', 'fs-1', {path: '/uploads', uid: 1000, gid: 1000, permissions: '755'})
})
test('attaches the file system to a function through an access point', async () => {
const user = userEvent.setup()
renderPanel(fileSystem())
const form = await screen.findByRole('form', {name: 'Attach to a function'})
await user.selectOptions(within(form).getByLabelText('Function'), await within(form).findByRole('option', {name: 'reader'}))
await user.selectOptions(within(form).getByLabelText('Access point'), 'fsap-1')
await user.click(within(form).getByRole('button', {name: 'Attach'}))
expect(api.attachFileSystemToFunction).toHaveBeenCalledWith('aws', 'fs-1', {accessPointId: 'fsap-1', functionName: 'reader', mountPath: '/mnt/data'})
expect(await screen.findByText('reader now sees this file system at /mnt/data.')).toBeInTheDocument()
})
test('detaches a function after confirming', async () => {
const user = userEvent.setup()
vi.spyOn(window, 'confirm').mockReturnValue(true)
renderPanel(fileSystem())
await user.click(await screen.findByRole('button', {name: 'Detach writer'}))
expect(api.detachFileSystemFromFunction).toHaveBeenCalledWith('aws', 'fs-1', 'writer')
})
test('cannot attach until the file system has a mount target', async () => {
renderPanel(fileSystem({mountTargets: [], functions: []}))
expect(await screen.findByText(/No mount targets yet/)).toBeInTheDocument()
expect(within(screen.getByRole('form', {name: 'Attach to a function'})).getByRole('button', {name: 'Attach'})).toBeDisabled()
})
@@ -0,0 +1,191 @@
import {RefreshCw} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {Link} from 'react-router-dom'
import {
attachFileSystemToFunction,
createAccessPoint,
createMountTarget,
deleteAccessPoint,
deleteMountTarget,
detachFileSystemFromFunction,
getCloudResource,
listCloudResources,
} from '@/api/cloudProxyClient'
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
import {useAccountId} from '@/lib/accountStore'
import type {CloudResource} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
type MountTarget = {id: string; subnetId: string | null; availabilityZone: string | null; ipAddress: string | null; state: string | null}
type AccessPoint = {id: string; arn: string | null; name: string | null; path: string; uid: number | null; gid: number | null; permissions: string | null; state: string | null}
type MountedFunction = {name: string; accessPointId: string | null; mountPath: string | null}
const EMPTY_ACCESS_POINT = {name: '', path: '/', uid: '1000', gid: '1000', permissions: '755'}
export function FileSystemPanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [subnetId, setSubnetId] = useState('')
const [accessPointForm, setAccessPointForm] = useState(EMPTY_ACCESS_POINT)
const [attachForm, setAttachForm] = useState({accessPointId: '', functionName: '', mountPath: '/mnt/data'})
const [notice, setNotice] = useState('')
const detailKey = ['efs-file-system', accountId, resource?.id]
const detailQuery = useQuery({
queryKey: detailKey,
queryFn: ({signal}) => getCloudResource('aws', 'filesystems', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
})
const functionsQuery = useQuery({
queryKey: cloudQueryKeys.resources('aws', 'serverless'),
queryFn: ({signal}) => listCloudResources('aws', 'serverless', undefined, signal),
enabled: Boolean(resource) && runtimeReachable,
})
useEffect(() => {
setNotice('')
setSubnetId('')
setAccessPointForm(EMPTY_ACCESS_POINT)
setAttachForm({accessPointId: '', functionName: '', mountPath: '/mnt/data'})
}, [resource?.id])
const settle = (updated: CloudResource, message: string) => {
queryClient.setQueryData(detailKey, updated)
setNotice(message)
return queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources('aws', 'filesystems')})
}
const id = resource?.id ?? ''
const addMountTarget = useMutation({
mutationFn: () => createMountTarget('aws', id, subnetId.trim()),
onSuccess: (updated) => { setSubnetId(''); return settle(updated, `Added a mount target in ${subnetId.trim()}.`) },
})
const removeMountTarget = useMutation({
mutationFn: (mountTargetId: string) => deleteMountTarget('aws', id, mountTargetId),
onSuccess: (updated, mountTargetId) => settle(updated, `Removed mount target ${mountTargetId}.`),
})
const addAccessPoint = useMutation({
mutationFn: () => createAccessPoint('aws', id, {
...(accessPointForm.name.trim() ? {name: accessPointForm.name.trim()} : {}),
path: accessPointForm.path.trim(),
uid: Number(accessPointForm.uid),
gid: Number(accessPointForm.gid),
permissions: accessPointForm.permissions.trim(),
}),
onSuccess: (updated) => { setAccessPointForm(EMPTY_ACCESS_POINT); return settle(updated, `Created an access point at ${accessPointForm.path.trim()}.`) },
})
const removeAccessPoint = useMutation({
mutationFn: (accessPointId: string) => deleteAccessPoint('aws', id, accessPointId),
onSuccess: (updated, accessPointId) => settle(updated, `Deleted access point ${accessPointId}.`),
})
const attach = useMutation({
mutationFn: () => attachFileSystemToFunction('aws', id, {...attachForm, mountPath: attachForm.mountPath.trim()}),
onSuccess: (updated) => settle(updated, `${attachForm.functionName} now sees this file system at ${attachForm.mountPath.trim()}.`),
})
const detach = useMutation({
mutationFn: (functionName: string) => detachFileSystemFromFunction('aws', id, functionName),
onSuccess: (updated, functionName) => settle(updated, `Detached ${functionName}.`),
})
if (!resource) {
return <section className="table-panel"><div className="empty compact"><h3>Select a file system</h3><p>Choose a file system to manage its mount targets and access points, and attach it to Lambda functions.</p></div></section>
}
const fileSystem = detailQuery.data ?? resource
const metadata = fileSystem.metadata
const mountTargets = Array.isArray(metadata.mountTargets) ? metadata.mountTargets as MountTarget[] : []
const accessPoints = Array.isArray(metadata.accessPoints) ? metadata.accessPoints as AccessPoint[] : []
const mounted = Array.isArray(metadata.functions) ? metadata.functions as MountedFunction[] : []
const functionNames = (functionsQuery.data ?? []).map((fn) => fn.name).sort()
const mutations = [addMountTarget, removeMountTarget, addAccessPoint, removeAccessPoint, attach, detach]
const disabled = !runtimeReachable || mutations.some((mutation) => mutation.isPending)
const error = detailQuery.error ?? mutations.find((mutation) => mutation.error)?.error
const accessPointLabel = (accessPointId: string | null) => {
const accessPoint = accessPoints.find((candidate) => candidate.id === accessPointId)
return accessPoint ? `${accessPoint.name ?? accessPoint.id} (${accessPoint.path})` : accessPointId ?? '—'
}
const run = (action: () => void) => { setNotice(''); action() }
return <section className="table-panel" style={{marginTop: 16}} aria-label={`File system ${fileSystem.name}`}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">EFS file system</p><span className="muted mono">{String(metadata.fileSystemId ?? fileSystem.id)}</span><span className="muted"> · {String(metadata.throughputMode ?? '')} · {metadata.encrypted ? 'encrypted' : 'not encrypted'}</span></div>
<div className="cdn-actions">
<button className="button" type="button" disabled={!runtimeReachable || detailQuery.isFetching} onClick={() => { void detailQuery.refetch() }}><RefreshCw size={14}/>{detailQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
</div>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Mount targets</p><span className="muted">Functions and tasks reach the file system through a mount target in their subnet.</span></div></div>
{mountTargets.length === 0 ? <p className="muted compact-text efs-note">No mount targets yet. Add one before attaching the file system to a function.</p>
: <div className="cognito-table"><table className="table"><thead><tr><th>Mount target</th><th>Subnet</th><th>Zone</th><th>IP address</th><th>State</th><th aria-label="Actions"/></tr></thead><tbody>{mountTargets.map((mountTarget) => (
<tr key={mountTarget.id}>
<td className="mono">{mountTarget.id}</td>
<td className="mono">{mountTarget.subnetId ?? '—'}</td>
<td>{mountTarget.availabilityZone ?? '—'}</td>
<td className="mono">{mountTarget.ipAddress ?? '—'}</td>
<td><code>{mountTarget.state ?? '—'}</code></td>
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Remove ${mountTarget.id}`} onClick={() => run(() => {
if (window.confirm(`Remove mount target ${mountTarget.id}?`)) removeMountTarget.mutate(mountTarget.id)
})}>Remove</button></td>
</tr>
))}</tbody></table></div>}
<form className="dynamic-form efs-inline-form" aria-label="Add mount target" onSubmit={(event) => { event.preventDefault(); run(() => addMountTarget.mutate()) }}>
<label className="dynamic-field"><span>Subnet ID</span><input className="input mono" value={subnetId} placeholder="subnet-…" onChange={(event) => setSubnetId(event.target.value)} disabled={disabled} required/></label>
<div><button className="button" type="submit" disabled={disabled || !subnetId.trim()}>{addMountTarget.isPending ? 'Adding' : 'Add mount target'}</button></div>
</form>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Access points</p><span className="muted">An entry point into one directory, as a fixed POSIX user. Lambda mounts a file system through an access point.</span></div></div>
{accessPoints.length === 0 ? <p className="muted compact-text efs-note">No access points yet.</p>
: <div className="cognito-table"><table className="table"><thead><tr><th>Access point</th><th>Root directory</th><th>POSIX user</th><th>State</th><th aria-label="Actions"/></tr></thead><tbody>{accessPoints.map((accessPoint) => (
<tr key={accessPoint.id}>
<td>{accessPoint.name && <>{accessPoint.name}<br/></>}<small className="mono">{accessPoint.id}</small></td>
<td className="mono">{accessPoint.path}{accessPoint.permissions ? <small className="muted"> ({accessPoint.permissions})</small> : null}</td>
<td className="mono">{accessPoint.uid ?? '—'}:{accessPoint.gid ?? '—'}</td>
<td><code>{accessPoint.state ?? '—'}</code></td>
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Delete ${accessPoint.id}`} onClick={() => run(() => {
if (window.confirm(`Delete access point ${accessPoint.id}? Files stay in the file system.`)) removeAccessPoint.mutate(accessPoint.id)
})}>Delete</button></td>
</tr>
))}</tbody></table></div>}
<form className="dynamic-form efs-access-point-form" aria-label="Create access point" onSubmit={(event) => { event.preventDefault(); run(() => addAccessPoint.mutate()) }}>
<label className="dynamic-field"><span>Name</span><input className="input" value={accessPointForm.name} onChange={(event) => setAccessPointForm({...accessPointForm, name: event.target.value})} disabled={disabled}/></label>
<label className="dynamic-field"><span>Root directory</span><input className="input mono" value={accessPointForm.path} onChange={(event) => setAccessPointForm({...accessPointForm, path: event.target.value})} disabled={disabled} required pattern="/.*" title="Start with /"/></label>
<label className="dynamic-field"><span>User ID</span><input className="input" type="number" min={0} value={accessPointForm.uid} onChange={(event) => setAccessPointForm({...accessPointForm, uid: event.target.value})} disabled={disabled} required/></label>
<label className="dynamic-field"><span>Group ID</span><input className="input" type="number" min={0} value={accessPointForm.gid} onChange={(event) => setAccessPointForm({...accessPointForm, gid: event.target.value})} disabled={disabled} required/></label>
<label className="dynamic-field"><span>Permissions</span><input className="input mono" value={accessPointForm.permissions} onChange={(event) => setAccessPointForm({...accessPointForm, permissions: event.target.value})} disabled={disabled} required pattern="[0-7]{3,4}" title="Octal, such as 755"/></label>
<div><button className="button" type="submit" disabled={disabled}>{addAccessPoint.isPending ? 'Creating' : 'Create access point'}</button></div>
</form>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Lambda functions</p><span className="muted">Functions that mount this file system share its files. A function can mount one file system.</span></div></div>
{mounted.length === 0 ? <p className="muted compact-text efs-note">No function mounts this file system.</p>
: <div className="cognito-table"><table className="table"><thead><tr><th>Function</th><th>Mount path</th><th>Access point</th><th aria-label="Actions"/></tr></thead><tbody>{mounted.map((fn) => (
<tr key={fn.name}>
<td><Link to="/cloud-explorer/aws/serverless" className="mono">{fn.name}</Link></td>
<td className="mono">{fn.mountPath ?? '—'}</td>
<td>{accessPointLabel(fn.accessPointId)}</td>
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Detach ${fn.name}`} onClick={() => run(() => {
if (window.confirm(`Detach the file system from ${fn.name}?`)) detach.mutate(fn.name)
})}>Detach</button></td>
</tr>
))}</tbody></table></div>}
{accessPoints.length > 0 && <form className="dynamic-form efs-access-point-form" aria-label="Attach to a function" onSubmit={(event) => { event.preventDefault(); run(() => attach.mutate()) }}>
<label className="dynamic-field"><span>Function</span><select className="input" value={attachForm.functionName} onChange={(event) => setAttachForm({...attachForm, functionName: event.target.value})} disabled={disabled} required>
<option value="">{functionNames.length === 0 ? 'No functions yet' : 'Choose a function'}</option>
{functionNames.map((name) => <option key={name} value={name}>{name}</option>)}
</select></label>
<label className="dynamic-field"><span>Access point</span><select className="input" value={attachForm.accessPointId} onChange={(event) => setAttachForm({...attachForm, accessPointId: event.target.value})} disabled={disabled} required>
<option value="">Choose an access point</option>
{accessPoints.map((accessPoint) => <option key={accessPoint.id} value={accessPoint.id}>{accessPointLabel(accessPoint.id)}</option>)}
</select></label>
<label className="dynamic-field"><span>Mount path</span><input className="input mono" value={attachForm.mountPath} onChange={(event) => setAttachForm({...attachForm, mountPath: event.target.value})} disabled={disabled} required pattern="/mnt/[a-zA-Z0-9\-_.]+" title="Under /mnt, such as /mnt/data"/></label>
<div><button className="button" type="submit" disabled={disabled || mountTargets.length === 0} title={mountTargets.length === 0 ? 'Add a mount target first' : undefined}>{attach.isPending ? 'Attaching' : 'Attach'}</button></div>
</form>}
{accessPoints.length > 0 && <p className="muted compact-text efs-note">Lambda needs a VPC to mount a file system. A function not yet in one is put in this file system&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, Database,
Earth, Earth,
FileBadge, FileBadge,
FolderTree,
Globe, Globe,
HardDrive, HardDrive,
Hammer, 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 route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/Route-53.svg'
import cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Delivery/CloudFront.svg' import cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Delivery/CloudFront.svg'
import autoScalingIcon from '../../../../aws-icons-svg/Compute/EC2-Auto-Scaling.svg' import 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 certificateManagerIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Certificate-Manager.svg'
import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg' import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg'
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg' import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
@@ -101,6 +103,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
dns: Globe, dns: Globe,
cdn: Earth, cdn: Earth,
autoscaling: Scaling, autoscaling: Scaling,
filesystems: FolderTree,
certificates: FileBadge, certificates: FileBadge,
monitoring: Activity, monitoring: Activity,
loadbalancing: Scale, loadbalancing: Scale,
@@ -151,6 +154,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
dns: route53Icon, dns: route53Icon,
cdn: cloudFrontIcon, cdn: cloudFrontIcon,
autoscaling: autoScalingIcon, autoscaling: autoScalingIcon,
filesystems: efsIcon,
certificates: certificateManagerIcon, certificates: certificateManagerIcon,
monitoring: cloudWatchIcon, monitoring: cloudWatchIcon,
workflows: stepFunctionsIcon, 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-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-note { padding: 0 16px 12px; margin: 0; }
.autoscaling-instance-actions { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; } .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 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 { export interface DistributionInvalidation {
id: string id: string
status: 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 DistributionActionName = 'setEnabled' | 'createInvalidation'
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance' export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
export type CertificateActionName = 'download' | 'export' | 'revoke' 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 MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth' export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth'
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon' export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
@@ -95,6 +96,7 @@ export interface ServiceSchema {
distributionActions?: Array<CapabilitySchema<DistributionActionName>> distributionActions?: Array<CapabilitySchema<DistributionActionName>>
autoScalingActions?: Array<CapabilitySchema<AutoScalingActionName>> autoScalingActions?: Array<CapabilitySchema<AutoScalingActionName>>
certificateActions?: Array<CapabilitySchema<CertificateActionName>> certificateActions?: Array<CapabilitySchema<CertificateActionName>>
fileSystemActions?: Array<CapabilitySchema<FileSystemActionName>>
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>> monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
} }
filters: FieldSchema[] filters: FieldSchema[]
+18
View File
@@ -61,6 +61,9 @@ importers:
'@aws-sdk/client-ecs': '@aws-sdk/client-ecs':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1145.0 version: 3.1145.0
'@aws-sdk/client-efs':
specifier: ^3.1146.0
version: 3.1146.0
'@aws-sdk/client-eks': '@aws-sdk/client-eks':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1076.0 version: 3.1076.0
@@ -375,6 +378,10 @@ packages:
resolution: {integrity: sha512-r3Kfyv+ax47DcXUKgMV/n9xUhKL1KjDBo3HC6+4CL+lOWXXYXsEJIWpQrDZYVwX8nbxuD7TXVFfEeY/38dmD1Q==} resolution: {integrity: sha512-r3Kfyv+ax47DcXUKgMV/n9xUhKL1KjDBo3HC6+4CL+lOWXXYXsEJIWpQrDZYVwX8nbxuD7TXVFfEeY/38dmD1Q==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/client-efs@3.1146.0':
resolution: {integrity: sha512-hu8iQM3KlhUn7zAS5iZEi0Vf6R/NaCPZXiQdqcVZYBwABnqUM4aTgdZmnhVM1I1+hpE/xWZ+HAfOs/aygGYifw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-eks@3.1076.0': '@aws-sdk/client-eks@3.1076.0':
resolution: {integrity: sha512-Hqr3hdwUgqWAQbdx6UDy81UBF+jdu3ws6RrykQ4YmBhe/XOKv57lt2lhkjgBuF9fiSw4UUl2XQteks9oxgm4XA==} resolution: {integrity: sha512-Hqr3hdwUgqWAQbdx6UDy81UBF+jdu3ws6RrykQ4YmBhe/XOKv57lt2lhkjgBuF9fiSw4UUl2XQteks9oxgm4XA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -3113,6 +3120,17 @@ snapshots:
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/client-efs@3.1146.0':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/credential-provider-node': 3.972.88
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/client-eks@3.1076.0': '@aws-sdk/client-eks@3.1076.0':
dependencies: dependencies:
'@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0