From 9b208a6d371b6e98eabaf6a5b72461c953054e49 Mon Sep 17 00:00:00 2001 From: Yash Datre Date: Tue, 18 Feb 2020 18:51:47 -0600 Subject: [PATCH] feat(aws-efs): adding construct library for creating EFS * 100% unit test coverage. * This was tested by creating an EFS using this construct in a cdk application. A instance was also created in this app, which successfully mounted it. closes #6286 --- packages/@aws-cdk/aws-efs/README.md | 25 ++ .../@aws-cdk/aws-efs/lib/efs-file-system.ts | 260 ++++++++++++++++++ packages/@aws-cdk/aws-efs/lib/file-system.ts | 38 +++ packages/@aws-cdk/aws-efs/lib/index.ts | 2 + packages/@aws-cdk/aws-efs/package.json | 16 +- .../aws-efs/test/efs-file-system.test.ts | 205 ++++++++++++++ packages/@aws-cdk/aws-efs/test/efs.test.ts | 6 - 7 files changed, 544 insertions(+), 8 deletions(-) create mode 100644 packages/@aws-cdk/aws-efs/lib/efs-file-system.ts create mode 100644 packages/@aws-cdk/aws-efs/lib/file-system.ts create mode 100644 packages/@aws-cdk/aws-efs/test/efs-file-system.test.ts delete mode 100644 packages/@aws-cdk/aws-efs/test/efs.test.ts diff --git a/packages/@aws-cdk/aws-efs/README.md b/packages/@aws-cdk/aws-efs/README.md index d053006e4d988..eb953f971556b 100644 --- a/packages/@aws-cdk/aws-efs/README.md +++ b/packages/@aws-cdk/aws-efs/README.md @@ -15,4 +15,29 @@ --- +This construct library allows you to set up AWS Elastic File System (EFS). + +```ts +import efs = require('@aws-cdk/aws-efs'); + +const myVpc = new ec2.Vpc(this, 'VPC'); +const fileSystem = new efs.EfsFileSystem(this, 'MyEfsFileSystem', { + vpc: myVpc, + encrypted: true, + lifecyclePolicy: EfsLifecyclePolicyProperty.AFTER_14_DAYS, + performanceMode: EfsPerformanceMode.GENERAL_PURPOSE, + throughputMode: EfsThroughputMode.BURSTING +}); +``` + +### Connecting + +To control who can access the EFS, use the `.connections` attribute. EFS has +a fixed default port, so you don't need to specify the port: + +```ts +fileSystem.connections.allowDefaultPortFrom(instance); +``` + + This module is part of the [AWS Cloud Development Kit](https://github.com/aws/aws-cdk) project. diff --git a/packages/@aws-cdk/aws-efs/lib/efs-file-system.ts b/packages/@aws-cdk/aws-efs/lib/efs-file-system.ts new file mode 100644 index 0000000000000..dd6806b951da6 --- /dev/null +++ b/packages/@aws-cdk/aws-efs/lib/efs-file-system.ts @@ -0,0 +1,260 @@ +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as kms from '@aws-cdk/aws-kms'; +import {Construct, Resource, Tag} from "@aws-cdk/core"; +import {CfnFileSystem, CfnMountTarget} from "./efs.generated"; +import {FileSystemProps, IFileSystem} from "./file-system"; + +/** + * EFS Lifecycle Policy, if a file is not accessed for given days, it will move to EFS Infrequent Access. + * + * @see http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-efs-filesystem.html#cfn-efs-filesystem-performancemode + */ +export enum EfsLifecyclePolicyProperty { + /** + * After 7 days of inaccessibility. + */ + AFTER_7_DAYS, + + /** + * After 14 days of inaccessibility. + */ + AFTER_14_DAYS, + + /** + * After 30 days of inaccessibility. + */ + AFTER_30_DAYS, + + /** + * After 60 days of inaccessibility. + */ + AFTER_60_DAYS, + + /** + * After 90 days of inaccessibility. + */ + AFTER_90_DAYS +} + +/** + * EFS Performance mode. + * + * @see http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-efs-filesystem.html#cfn-efs-filesystem-performancemode + */ +export enum EfsPerformanceMode { + /** + * This is the general purpose performance mode for most file systems. + */ + GENERAL_PURPOSE = "generalPurpose", + + /** + * This performance mode can scale to higher levels of aggregate throughput and operations per second with a + * tradeoff of slightly higher latencies. + */ + MAX_IO = "maxIO" +} + +/** + * EFS Throughput mode. + * + * @see http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-efs-filesystem.html#cfn-elasticfilesystem-filesystem-throughputmode + */ +export enum EfsThroughputMode { + /** + * This mode on Amazon EFS scales as the size of the file system in the standard storage class grows. + */ + BURSTING = "bursting", + + /** + * This mode can instantly provision the throughput of the file system (in MiB/s) independent of the amount of data stored. + */ + PROVISIONED = "provisioned" +} + +/** + * Properties of EFS FileSystem. + */ +export interface EfsFileSystemProps extends FileSystemProps { + /** + * Defines if the data at rest in the file system is encrypted or not. + * + * @default - false + */ + readonly encrypted?: boolean; + + /** + * The KMS key used for encryption. This is required to encrypt the data at rest if @encrypted is set to true. + * + * @default - if @encrypted is true, the default key for EFS (/aws/elasticfilesystem) is used + */ + readonly kmsKey?: kms.IKey; + + /** + * The key value pair added to the File system. + * + * @default - no tags will be added + */ + readonly fileSystemTags?: Tag[]; + + /** + * A policy used by EFS lifecycle management to transition files to the Infrequent Access (IA) storage class. + * + * @default - none + */ + readonly lifecyclePolicy?: EfsLifecyclePolicyProperty; + + /** + * Enum to mention the performance mode of the file system. + * + * @default - GENERAL_PURPOSE + */ + readonly performanceMode?: EfsPerformanceMode; + + /** + * Enum to mention the throughput mode of the file system. + * + * @default - BURSTING + */ + readonly throughputMode?: EfsThroughputMode; + + /** + * Provisioned throughput for the file system. This is a required property if the throughput mode is set to PROVISIONED. + * Valid values are 1-1024. + * + * @default - None, errors out + */ + readonly provisionedThroughputInMibps?: number; +} + +/** + * A new or imported EFS File System. + */ +abstract class EFSFileSystemBase extends Resource implements IFileSystem { + + /** + * The security groups/rules used to allow network connections to the file system. + */ + public abstract readonly connections: ec2.Connections; + + /** + * @attribute + */ + public abstract readonly fileSystemID: string; +} + +/** + * Properties that describe an existing EFS file system. + */ +export interface EfsFileSystemAttributes { + /** + * The security group of the file system + */ + readonly securityGroup: ec2.ISecurityGroup; + + /** + * The File System's ID. + */ + readonly fileSystemID: string; +} + +/** + * The Elastic File System implementation of IFileSystem. + * It creates a new, empty file system in Amazon Elastic File System (Amazon EFS). + * It also creates mount target (AWS::EFS::MountTarget) implicitly to mount the + * EFS file system on an Amazon Elastic Compute Cloud (Amazon EC2) instance or another resource. + * + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-efs-filesystem.html + * + * @resource AWS::EFS::FileSystem + */ +export class EfsFileSystem extends EFSFileSystemBase { + + /** + * Import an existing File System from the given properties. + */ + public static fromEfsFileSystemAttributes(scope: Construct, id: string, attrs: EfsFileSystemAttributes): IFileSystem { + class Import extends EFSFileSystemBase implements IFileSystem { + public readonly fileSystemID = attrs.fileSystemID; + public readonly connections = new ec2.Connections({ + securityGroups: [attrs.securityGroup], + defaultPort: ec2.Port.tcp(EfsFileSystem.DEFAULT_PORT) + }); + } + + return new Import(scope, id); + } + + /** + * The default port File System listens on. + */ + private static readonly DEFAULT_PORT: number = 2049; + + /** + * The security groups/rules used to allow network connections to the file system. + */ + public readonly connections: ec2.Connections; + + /** + * @attribute + */ + public readonly fileSystemID: string; + + private readonly mountTargets: CfnMountTarget[] = []; + private readonly efsFileSystem: CfnFileSystem; + + /** + * Constructor for creating a new EFS FileSystem. + */ + constructor(scope: Construct, id: string, props: EfsFileSystemProps) { + super(scope, id); + + if (props.throughputMode === EfsThroughputMode.PROVISIONED) { + if (props.provisionedThroughputInMibps === undefined) { + throw new Error('Property provisionedThroughputInMibps is required when throughputMode is PROVISIONED'); + } else if (!Number.isInteger(props.provisionedThroughputInMibps)) { + throw new Error("Invalid input for provisionedThroughputInMibps"); + } else if (props.provisionedThroughputInMibps < 1 || props.provisionedThroughputInMibps > 1024) { + this.node.addWarning("Valid values for throughput are 1-1024 MiB/s. You can get this limit increased by contacting AWS Support."); + } + } + + this.efsFileSystem = new CfnFileSystem(this, "FileSystem", { + encrypted: props.encrypted, + kmsKeyId: (props.kmsKey ? props.kmsKey.keyId : undefined), + fileSystemTags: props.fileSystemTags, + lifecyclePolicies: (props.lifecyclePolicy ? Array.of({ + transitionToIa: EfsLifecyclePolicyProperty[props.lifecyclePolicy] + } as CfnFileSystem.LifecyclePolicyProperty) : undefined), + performanceMode: props.performanceMode, + throughputMode: props.throughputMode, + provisionedThroughputInMibps: props.provisionedThroughputInMibps + }); + + this.fileSystemID = this.efsFileSystem.ref; + this.node.defaultChild = this.efsFileSystem; + + const securityGroup = (props.securityGroup || new ec2.SecurityGroup(this, 'EfsSecurityGroup', { + vpc: props.vpc + })); + + this.connections = new ec2.Connections({ + securityGroups: [securityGroup], + defaultPort: ec2.Port.tcp(EfsFileSystem.DEFAULT_PORT) + }); + + const subnets = props.vpc.selectSubnets(props.vpcSubnets); + + // We now have to create the mount target for each of the mentioned subnet + let mountTargetCount = 0; + subnets.subnetIds.forEach((subnetId: string) => { + const efsMountTarget = new CfnMountTarget(this, + "EfsMountTarget" + (++mountTargetCount), + { + fileSystemId: this.fileSystemID, + securityGroups: Array.of(securityGroup.securityGroupId), + subnetId + }); + this.mountTargets.push(efsMountTarget); + }); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-efs/lib/file-system.ts b/packages/@aws-cdk/aws-efs/lib/file-system.ts new file mode 100644 index 0000000000000..0972d1f3c1185 --- /dev/null +++ b/packages/@aws-cdk/aws-efs/lib/file-system.ts @@ -0,0 +1,38 @@ +import * as ec2 from '@aws-cdk/aws-ec2'; +import {IResource} from "@aws-cdk/core"; + +/** + * Interface to implement AWS File Systems. + */ +export interface IFileSystem extends IResource, ec2.IConnectable { + /** + * The ID of the file system, assigned by Amazon EFS. + * + * @attribute + */ + readonly fileSystemID: string; +} + +/** + * Properties of FileSystem + */ +export interface FileSystemProps { + /** + * VPC to launch the file system in. + */ + readonly vpc: ec2.IVpc; + + /** + * Security Group to assign to this file system. + * + * @default - creates new security group which allow all out bound trafficcloudformation + */ + readonly securityGroup?: ec2.ISecurityGroup; + + /** + * Where to place the mount target within the VPC. + * + * @default - Private subnets + */ + readonly vpcSubnets?: ec2.SubnetSelection; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-efs/lib/index.ts b/packages/@aws-cdk/aws-efs/lib/index.ts index 0f93e4419ab6b..6bc50a7d050f5 100644 --- a/packages/@aws-cdk/aws-efs/lib/index.ts +++ b/packages/@aws-cdk/aws-efs/lib/index.ts @@ -1,2 +1,4 @@ // AWS::EFS CloudFormation Resources: +export * from './file-system'; +export * from './efs-file-system'; export * from './efs.generated'; diff --git a/packages/@aws-cdk/aws-efs/package.json b/packages/@aws-cdk/aws-efs/package.json index 9e8e3a9d9b88c..4e617cef6fe5b 100644 --- a/packages/@aws-cdk/aws-efs/package.json +++ b/packages/@aws-cdk/aws-efs/package.json @@ -85,14 +85,26 @@ "pkglint": "1.24.0" }, "dependencies": { - "@aws-cdk/core": "1.24.0" + "@aws-cdk/core": "1.24.0", + "@aws-cdk/aws-ec2": "1.24.0", + "@aws-cdk/aws-kms": "1.24.0", + "@aws-cdk/cx-api": "1.24.0" }, "homepage": "https://github.com/aws/aws-cdk", "peerDependencies": { - "@aws-cdk/core": "1.24.0" + "@aws-cdk/core": "1.24.0", + "@aws-cdk/aws-ec2": "1.24.0", + "@aws-cdk/aws-kms": "1.24.0", + "@aws-cdk/cx-api": "1.24.0" }, "engines": { "node": ">= 10.3.0" }, + "awslint": { + "exclude": [ + "props-physical-name:@aws-cdk/aws-efs.EfsFileSystemProps", + "resource-interface:@aws-cdk/aws-efs.EfsFileSystem" + ] + }, "stability": "experimental" } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-efs/test/efs-file-system.test.ts b/packages/@aws-cdk/aws-efs/test/efs-file-system.test.ts new file mode 100644 index 0000000000000..0a14b2b413067 --- /dev/null +++ b/packages/@aws-cdk/aws-efs/test/efs-file-system.test.ts @@ -0,0 +1,205 @@ +import {expect as expectCDK, haveResource} from '@aws-cdk/assert'; +import * as ec2 from "@aws-cdk/aws-ec2"; +import * as kms from "@aws-cdk/aws-kms"; +import {Stack, Tag} from "@aws-cdk/core"; +import {WARNING_METADATA_KEY} from "@aws-cdk/cx-api"; +import {EfsFileSystem, EfsLifecyclePolicyProperty, EfsPerformanceMode, EfsThroughputMode} from "../lib/efs-file-system"; + +let stack = new Stack(); +let vpc = new ec2.Vpc(stack, 'VPC'); + +beforeEach( () => { + stack = new Stack(); + vpc = new ec2.Vpc(stack, 'VPC'); +}); + +test('default file system is created correctly', () => { + // WHEN + new EfsFileSystem(stack, 'EfsFileSystem', { + vpc, + }); + // THEN + expectCDK(stack).to(haveResource('AWS::EFS::FileSystem')); + expectCDK(stack).to(haveResource('AWS::EFS::MountTarget')); + expectCDK(stack).to(haveResource('AWS::EC2::SecurityGroup')); +}); + +test('unencrypted file system is created correctly with default KMS', () => { + // WHEN + new EfsFileSystem(stack, 'EfsFileSystem', { + vpc, + encrypted: false + }); + // THEN + expectCDK(stack).notTo(haveResource('AWS::EFS::FileSystem', { + Encrypted: true, + })); +}); + +test('encrypted file system is created correctly with default KMS', () => { + // WHEN + new EfsFileSystem(stack, 'EfsFileSystem', { + vpc, + encrypted: true + }); + // THEN + expectCDK(stack).to(haveResource('AWS::EFS::FileSystem', { + Encrypted: true, + })); +}); + +test('encrypted file system is created correctly with custom KMS', () => { + const key = new kms.Key(stack, 'customKeyFS'); + + // WHEN + new EfsFileSystem(stack, 'EfsFileSystem', { + vpc, + encrypted: true, + kmsKey: key + }); + // THEN + + /** + * CDK appends 8-digit MD5 hash of the resource path to the logical Id of the resource in order to make sure + * that the id is unique across multiple stacks. There isnt a direct way to identify the exact name of the resource + * in generated CDK, hence hardcoding the MD5 hash here for assertion. Assumption is that the path of the Key wont + * change in this UT. Checked the unique id by generating the cloud formation stack. + */ + expectCDK(stack).to(haveResource('AWS::EFS::FileSystem', { + Encrypted: true, + KmsKeyId: { + Ref: 'customKeyFSDDB87C6D' + } + })); +}); + +test('file system is created correctly with tags', () => { + // WHEN + new EfsFileSystem(stack, 'EfsFileSystem', { + vpc, + fileSystemTags: [new Tag("key1", "value1")] + }); + // THEN + expectCDK(stack).to(haveResource('AWS::EFS::FileSystem', { + FileSystemTags: [{ + Key: "key1", + Value: "value1" + }] + })); +}); + +test('file system is created correctly with life cycle property', () => { + // WHEN + new EfsFileSystem(stack, 'EfsFileSystem', { + vpc, + lifecyclePolicy: EfsLifecyclePolicyProperty.AFTER_14_DAYS + }); + // THEN + expectCDK(stack).to(haveResource('AWS::EFS::FileSystem', { + LifecyclePolicies: [{ + TransitionToIA: "AFTER_14_DAYS" + }] + })); +}); + +test('file system is created correctly with performance mode', () => { + // WHEN + new EfsFileSystem(stack, 'EfsFileSystem', { + vpc, + performanceMode: EfsPerformanceMode.MAX_IO + }); + // THEN + expectCDK(stack).to(haveResource('AWS::EFS::FileSystem', { + PerformanceMode: "maxIO" + })); +}); + +test('file system is created correctly with bursting throughput mode', () => { + // WHEN + new EfsFileSystem(stack, 'EfsFileSystem', { + vpc, + throughputMode: EfsThroughputMode.BURSTING + }); + // THEN + expectCDK(stack).to(haveResource('AWS::EFS::FileSystem', { + ThroughputMode: "bursting" + })); +}); + +test('Exception when throughput mode is set to PROVISIONED, but provisioned throughput is not set', () => { + expect(() => { + new EfsFileSystem(stack, 'EfsFileSystem', { + vpc, + throughputMode: EfsThroughputMode.PROVISIONED + }); + }).toThrowError(/Property provisionedThroughputInMibps is required when throughputMode is PROVISIONED/); +}); + +test('Warning when provisioned throughput is less than the valid range', () => { + const fileSystem = new EfsFileSystem(stack, 'EfsFileSystem', { + vpc, + throughputMode: EfsThroughputMode.PROVISIONED, + provisionedThroughputInMibps: 0 + }); + + expect(fileSystem.node.metadata[0].type).toMatch(WARNING_METADATA_KEY); + expect(fileSystem.node.metadata[0].data).toContain("Valid values for throughput are 1-1024 MiB/s"); + expect(fileSystem.node.metadata[0].data).toContain("You can get this limit increased by contacting AWS Support"); + + expectCDK(stack).to(haveResource('AWS::EFS::FileSystem')); +}); + +test('Warning when provisioned throughput is above than the valid range', () => { + const fileSystem = new EfsFileSystem(stack, 'EfsFileSystem1', { + vpc, + throughputMode: EfsThroughputMode.PROVISIONED, + provisionedThroughputInMibps: 1025 + }); + + expect(fileSystem.node.metadata[0].type).toMatch(WARNING_METADATA_KEY); + expect(fileSystem.node.metadata[0].data).toContain("Valid values for throughput are 1-1024 MiB/s"); + expect(fileSystem.node.metadata[0].data).toContain("You can get this limit increased by contacting AWS Support"); + + expectCDK(stack).to(haveResource('AWS::EFS::FileSystem')); +}); + +test('Error when provisioned throughput is invalid number', () => { + expect(() => { + new EfsFileSystem(stack, 'EfsFileSystem2', { + vpc, + throughputMode: EfsThroughputMode.PROVISIONED, + provisionedThroughputInMibps: 1.5 + }); + }).toThrowError(/Invalid input for provisionedThroughputInMibps/); +}); + +test('file system is created correctly with provisioned throughput mode', () => { + // WHEN + new EfsFileSystem(stack, 'EfsFileSystem', { + vpc, + throughputMode: EfsThroughputMode.PROVISIONED, + provisionedThroughputInMibps: 5 + }); + // THEN + expectCDK(stack).to(haveResource('AWS::EFS::FileSystem', { + ThroughputMode: "provisioned", + ProvisionedThroughputInMibps: 5 + })); +}); + +test('existing file system is imported correctly', () => { + // WHEN + const fs = EfsFileSystem.fromEfsFileSystemAttributes(stack, "existingFS", { + fileSystemID: "fs123", + securityGroup: ec2.SecurityGroup.fromSecurityGroupId(stack, 'SG', 'sg-123456789', { + allowAllOutbound: false + }) + }); + + fs.connections.allowToAnyIpv4(ec2.Port.tcp(443)); + + // THEN + expectCDK(stack).to(haveResource('AWS::EC2::SecurityGroupEgress', { + GroupId: 'sg-123456789', + })); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-efs/test/efs.test.ts b/packages/@aws-cdk/aws-efs/test/efs.test.ts deleted file mode 100644 index e394ef336bfb4..0000000000000 --- a/packages/@aws-cdk/aws-efs/test/efs.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import '@aws-cdk/assert/jest'; -import {} from '../lib'; - -test('No tests are specified for this package', () => { - expect(true).toBe(true); -});