From 1cce38a736d94a63409b3c8dc63231986a3913ed Mon Sep 17 00:00:00 2001 From: Duarte Nunes Date: Fri, 30 Jul 2021 01:10:16 -0300 Subject: [PATCH] feat(aws-s3objectlambda): add L2 construct for S3 Object Lambda --- .../@aws-cdk/aws-s3objectlambda/README.md | 8 + .../@aws-cdk/aws-s3objectlambda/lib/index.ts | 2 + .../aws-s3objectlambda/lib/object-lambda.ts | 166 ++++++++++++ .../@aws-cdk/aws-s3objectlambda/package.json | 16 +- .../test/integ.s3objectlambda.expected.json | 248 ++++++++++++++++++ .../test/integ.s3objectlambda.ts | 45 ++++ 6 files changed, 482 insertions(+), 3 deletions(-) create mode 100644 packages/@aws-cdk/aws-s3objectlambda/lib/object-lambda.ts create mode 100644 packages/@aws-cdk/aws-s3objectlambda/test/integ.s3objectlambda.expected.json create mode 100644 packages/@aws-cdk/aws-s3objectlambda/test/integ.s3objectlambda.ts diff --git a/packages/@aws-cdk/aws-s3objectlambda/README.md b/packages/@aws-cdk/aws-s3objectlambda/README.md index ebb6f1b79c7e6..2c1db89330407 100644 --- a/packages/@aws-cdk/aws-s3objectlambda/README.md +++ b/packages/@aws-cdk/aws-s3objectlambda/README.md @@ -9,6 +9,14 @@ > > [CFN Resources]: https://docs.aws.amazon.com/cdk/latest/guide/constructs.html#constructs_lib +![cdk-constructs: Experimental](https://img.shields.io/badge/cdk--constructs-experimental-important.svg?style=for-the-badge) + +> The APIs of higher level constructs in this module are experimental and under active development. +> They are subject to non-backward compatible changes or removal in any future version. These are +> not subject to the [Semantic Versioning](https://semver.org/) model and breaking changes will be +> announced in the release notes. This means that while you may use them, you may need to update +> your source code when upgrading to a newer version of this package. + --- diff --git a/packages/@aws-cdk/aws-s3objectlambda/lib/index.ts b/packages/@aws-cdk/aws-s3objectlambda/lib/index.ts index 791ddcf126933..cfd57d6d01b0a 100644 --- a/packages/@aws-cdk/aws-s3objectlambda/lib/index.ts +++ b/packages/@aws-cdk/aws-s3objectlambda/lib/index.ts @@ -1,2 +1,4 @@ +export * from './object-lambda'; + // AWS::S3ObjectLambda CloudFormation Resources: export * from './s3objectlambda.generated'; diff --git a/packages/@aws-cdk/aws-s3objectlambda/lib/object-lambda.ts b/packages/@aws-cdk/aws-s3objectlambda/lib/object-lambda.ts new file mode 100644 index 0000000000000..5fff22260f8da --- /dev/null +++ b/packages/@aws-cdk/aws-s3objectlambda/lib/object-lambda.ts @@ -0,0 +1,166 @@ +import * as iam from '@aws-cdk/aws-iam'; +import * as lambda from '@aws-cdk/aws-lambda'; +import * as s3 from '@aws-cdk/aws-s3'; +import { Stack } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnAccessPoint } from './s3objectlambda.generated'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct as CoreConstruct } from '@aws-cdk/core'; + +/** + * Creates an S3 Object Lambda, which can intercept and transform + * `GetObject` requests. + * + * @param fn The Lambda function + * @param props Configuration for this Object Lambda + */ +export interface ObjectLambdaProps { + /** + * The bucket to which this Object Lambda belongs + */ + readonly bucket: s3.IBucket + + /** + * The Lambda function used to transform objects. + */ + readonly fn: lambda.IFunction + + /** + * The name of the Object Lambda access point. + */ + readonly name: string + + /** + * Whether CloudWatch metrics are enabled for the Object Lambda. + * + * @default false + */ + readonly cloudWatchMetricsEnabled?: boolean + + /** + * Whether the Lambda function can process `GetObject-Range` requests. + * + * @default false + */ + readonly supportsGetObjectRange?: boolean + + /** + * Whether the Lambda function can process `GetObject-PartNumber` requests. + * + * @default false + */ + readonly supportsGetObjectPartNumber?: boolean + + /** + * Additional JSON that provides supplemental data passed to the + * Lambda function on every request. + * + * @default - No data. + */ + readonly payload?: string +} + +/** + * An S3 Object Lambda for intercepting and transforming `GetObject` requests. + */ +export class ObjectLambda extends CoreConstruct { + private readonly objectLambda: CfnAccessPoint + private readonly stack: Stack + + constructor(scope: Construct, id: string, props: ObjectLambdaProps) { + super(scope, id); + + this.stack = props.bucket.stack; + + const supporting = new s3.CfnAccessPoint(this, 'AccessPoint', { + bucket: props.bucket.bucketName, + // TODO: configure publicAccessBlockConfiguration? + }); + supporting.addPropertyOverride('Name', `${props.name}-access-point`); + + const allowedFeatures = []; + if (props.supportsGetObjectPartNumber) { + allowedFeatures.push('GetObject-PartNumber'); + } + if (props.supportsGetObjectRange) { + allowedFeatures.push('GetObject-Range'); + } + + this.objectLambda = new CfnAccessPoint(this, 'LambdaAccessPoint', { + name: props.name.toLowerCase(), + objectLambdaConfiguration: { + allowedFeatures, + cloudWatchMetricsEnabled: props.cloudWatchMetricsEnabled, + supportingAccessPoint: supporting.getAtt('Arn').toString(), + transformationConfigurations: [ + { + actions: ['GetObject'], + contentTransformation: { + AwsLambda: { + FunctionArn: props.fn.functionArn, + FunctionPayload: props.payload ?? '', + }, + }, + }, + ], + }, + }); + this.objectLambda.addDependsOn(supporting); + + props.fn.addToRolePolicy( + new iam.PolicyStatement({ + actions: ['s3-object-lambda:WriteGetObjectResponse'], + resources: ['*'], + }), + ); + } + + /** + * The ARN of the Object Lambda access point. + */ + get arn(): string { + return this.objectLambda.getAtt('Arn').toString(); + } + + /** + * The IPv4 DNS name of the Object Lambda access point. + */ + get domainName(): string { + const urlSuffix = this.stack.urlSuffix; + return `${this.objectLambda.name}-${this.stack.account}.s3.${urlSuffix}`; + } + + /** + * The regional domain name of the Object Lambda access point. + */ + get regionalDomainName(): string { + const urlSuffix = this.stack.urlSuffix; + const region = this.stack.region; + return `${this.objectLambda.name}-${this.stack.account}.s3.${region}.${urlSuffix}`; + } + + /** + * The virtual hosted-style URL of an S3 object through this access point. + * Specify `regional: false` at the options for non-regional URL. + * @param key The S3 key of the object. If not specified, the URL of the + * bucket is returned. + * @param options Options for generating URL. + * @returns an ObjectS3Url token + */ + public virtualHostedUrlForObject(key?: string, options?: s3.VirtualHostedStyleUrlOptions): string { + const domainName = options?.regional ?? true ? this.regionalDomainName : this.domainName; + const prefix = `https://${domainName}`; + if (typeof key !== 'string') { + return prefix; + } + if (key.startsWith('/')) { + key = key.slice(1); + } + if (key.endsWith('/')) { + key = key.slice(0, -1); + } + return `${prefix}/${key}`; + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3objectlambda/package.json b/packages/@aws-cdk/aws-s3objectlambda/package.json index 7818c261a21bc..4ccca5e420013 100644 --- a/packages/@aws-cdk/aws-s3objectlambda/package.json +++ b/packages/@aws-cdk/aws-s3objectlambda/package.json @@ -79,21 +79,31 @@ "devDependencies": { "@types/jest": "^26.0.24", "cdk-build-tools": "0.0.0", + "cdk-integ-tools": "0.0.0", "cfn2ts": "0.0.0", + "jest": "^26.6.3", "pkglint": "0.0.0", "@aws-cdk/assert-internal": "0.0.0" }, "dependencies": { - "@aws-cdk/core": "0.0.0" + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-lambda": "0.0.0", + "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/core": "0.0.0", + "constructs": "^3.3.69" }, "peerDependencies": { - "@aws-cdk/core": "0.0.0" + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-lambda": "0.0.0", + "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/core": "0.0.0", + "constructs": "^3.3.69" }, "engines": { "node": ">= 10.13.0 <13 || >=13.7.0" }, "stability": "experimental", - "maturity": "cfn-only", + "maturity": "experimental", "awscdkio": { "announce": false }, diff --git a/packages/@aws-cdk/aws-s3objectlambda/test/integ.s3objectlambda.expected.json b/packages/@aws-cdk/aws-s3objectlambda/test/integ.s3objectlambda.expected.json new file mode 100644 index 0000000000000..38e4f220dd89d --- /dev/null +++ b/packages/@aws-cdk/aws-s3objectlambda/test/integ.s3objectlambda.expected.json @@ -0,0 +1,248 @@ +{ + "Resources": { + "MyBucketF68F3FF0": { + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "MyFunction1ServiceRole9852B06B": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "MyFunction1ServiceRoleDefaultPolicy39556460": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "s3-object-lambda:WriteGetObjectResponse", + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "MyFunction1ServiceRoleDefaultPolicy39556460", + "Roles": [ + { + "Ref": "MyFunction1ServiceRole9852B06B" + } + ] + } + }, + "MyFunction12A744C2E": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "foo" + }, + "Role": { + "Fn::GetAtt": [ + "MyFunction1ServiceRole9852B06B", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs10.x" + }, + "DependsOn": [ + "MyFunction1ServiceRoleDefaultPolicy39556460", + "MyFunction1ServiceRole9852B06B" + ] + }, + "MyFunction2ServiceRole07E5BE0E": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "MyFunction2ServiceRoleDefaultPolicyA79C693E": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "s3-object-lambda:WriteGetObjectResponse", + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "MyFunction2ServiceRoleDefaultPolicyA79C693E", + "Roles": [ + { + "Ref": "MyFunction2ServiceRole07E5BE0E" + } + ] + } + }, + "MyFunction2F2A964CA": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "foo" + }, + "Role": { + "Fn::GetAtt": [ + "MyFunction2ServiceRole07E5BE0E", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs10.x" + }, + "DependsOn": [ + "MyFunction2ServiceRoleDefaultPolicyA79C693E", + "MyFunction2ServiceRole07E5BE0E" + ] + }, + "MyObjectLambda1AccessPointD5812646": { + "Type": "AWS::S3::AccessPoint", + "Properties": { + "Bucket": { + "Ref": "MyBucketF68F3FF0" + }, + "Name": "obj-lambda-1-access-point" + } + }, + "MyObjectLambda1LambdaAccessPoint73C4BD68": { + "Type": "AWS::S3ObjectLambda::AccessPoint", + "Properties": { + "Name": "obj-lambda-1", + "ObjectLambdaConfiguration": { + "AllowedFeatures": [ + "GetObject-PartNumber" + ], + "CloudWatchMetricsEnabled": true, + "SupportingAccessPoint": { + "Fn::GetAtt": [ + "MyObjectLambda1AccessPointD5812646", + "Arn" + ] + }, + "TransformationConfigurations": [ + { + "Actions": [ + "GetObject" + ], + "ContentTransformation": { + "AwsLambda": { + "FunctionArn": { + "Fn::GetAtt": [ + "MyFunction12A744C2E", + "Arn" + ] + }, + "FunctionPayload": "" + } + } + } + ] + } + }, + "DependsOn": [ + "MyObjectLambda1AccessPointD5812646" + ] + }, + "MyObjectLambda2AccessPoint76FB5ACF": { + "Type": "AWS::S3::AccessPoint", + "Properties": { + "Bucket": { + "Ref": "MyBucketF68F3FF0" + }, + "Name": "obj-lambda-1-access-point" + } + }, + "MyObjectLambda2LambdaAccessPoint1043EB83": { + "Type": "AWS::S3ObjectLambda::AccessPoint", + "Properties": { + "Name": "obj-lambda-1", + "ObjectLambdaConfiguration": { + "AllowedFeatures": [ + "GetObject-Range" + ], + "SupportingAccessPoint": { + "Fn::GetAtt": [ + "MyObjectLambda2AccessPoint76FB5ACF", + "Arn" + ] + }, + "TransformationConfigurations": [ + { + "Actions": [ + "GetObject" + ], + "ContentTransformation": { + "AwsLambda": { + "FunctionArn": { + "Fn::GetAtt": [ + "MyFunction2F2A964CA", + "Arn" + ] + }, + "FunctionPayload": "{foo: 10}" + } + } + } + ] + } + }, + "DependsOn": [ + "MyObjectLambda2AccessPoint76FB5ACF" + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3objectlambda/test/integ.s3objectlambda.ts b/packages/@aws-cdk/aws-s3objectlambda/test/integ.s3objectlambda.ts new file mode 100644 index 0000000000000..c770006a621e0 --- /dev/null +++ b/packages/@aws-cdk/aws-s3objectlambda/test/integ.s3objectlambda.ts @@ -0,0 +1,45 @@ +import * as lambda from '@aws-cdk/aws-lambda'; +import * as s3 from '@aws-cdk/aws-s3'; +import * as cdk from '@aws-cdk/core'; +import { ObjectLambda } from '../lib'; + +class TestStack extends cdk.Stack { + constructor(scope: cdk.App, id: string) { + super(scope, id); + + const bucket = new s3.Bucket(this, 'MyBucket'); + const fn1 = new lambda.Function(this, 'MyFunction1', { + runtime: lambda.Runtime.NODEJS_10_X, + handler: 'index.handler', + code: lambda.Code.fromInline('foo'), + }); + + const fn2 = new lambda.Function(this, 'MyFunction2', { + runtime: lambda.Runtime.NODEJS_10_X, + handler: 'index.handler', + code: lambda.Code.fromInline('foo'), + }); + + new ObjectLambda(this, 'MyObjectLambda1', { + bucket, + fn: fn1, + name: 'obj-lambda-1', + cloudWatchMetricsEnabled: true, + supportsGetObjectPartNumber: true, + }); + + new ObjectLambda(this, 'MyObjectLambda2', { + bucket, + fn: fn2, + name: 'obj-lambda-1', + supportsGetObjectRange: true, + payload: '{foo: 10}', + }); + } +} + +const app = new cdk.App(); + +new TestStack(app, 'aws-s3-object-lambda'); + +app.synth();