From cb7d1bef1f9a188ae6f27b9a1f5c30889a88d405 Mon Sep 17 00:00:00 2001 From: Nick Lynch Date: Thu, 17 Sep 2020 09:37:36 +0100 Subject: [PATCH] feat(cloudfront): Lambda@Edge construct DRAFT PR - Looking for early-stages feedback This PR creates a construct (`EdgeFunction`) for Lambda@Edge functions. CloudFront requires that a function be in us-east-1 to be used with Lambda@Edge, even if the logical distribution is created via another region. The initial goal of this construct is to make it easier to request and work with a function in us-east-1 when the primary stack is in another region. In the future, this can be extended to validate other Lambda@Edge restrictions. See https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-requirements-limits.html and #9833 for more information on those limitations. Some open questions: 1. Is there a more clever way to "refresh" the SSM parameter reader when the underlying function changes? 2. How to make this and `edgeArn` play nicely together? Thanks to @asterikx for the inspiration and consolidated writeup in #1575. fixes #9862 --- packages/@aws-cdk/aws-lambda/.gitignore | 4 +- .../@aws-cdk/aws-lambda/lib/edge-function.ts | 254 ++++++++++++++++++ .../aws-lambda/lib/edge-function/index.js | 20 ++ .../@aws-cdk/aws-lambda/lib/function-base.ts | 23 +- packages/@aws-cdk/aws-lambda/lib/index.ts | 1 + packages/@aws-cdk/aws-lambda/package.json | 3 + .../aws-lambda/test/test.edge-function.ts | 215 +++++++++++++++ 7 files changed, 507 insertions(+), 13 deletions(-) create mode 100644 packages/@aws-cdk/aws-lambda/lib/edge-function.ts create mode 100644 packages/@aws-cdk/aws-lambda/lib/edge-function/index.js create mode 100644 packages/@aws-cdk/aws-lambda/test/test.edge-function.ts diff --git a/packages/@aws-cdk/aws-lambda/.gitignore b/packages/@aws-cdk/aws-lambda/.gitignore index d0a956699806b..2996bd65f5de9 100644 --- a/packages/@aws-cdk/aws-lambda/.gitignore +++ b/packages/@aws-cdk/aws-lambda/.gitignore @@ -16,4 +16,6 @@ nyc.config.js *.snk !.eslintrc.js -junit.xml \ No newline at end of file +!lib/edge-function/index.js + +junit.xml diff --git a/packages/@aws-cdk/aws-lambda/lib/edge-function.ts b/packages/@aws-cdk/aws-lambda/lib/edge-function.ts new file mode 100644 index 0000000000000..b15173e2df955 --- /dev/null +++ b/packages/@aws-cdk/aws-lambda/lib/edge-function.ts @@ -0,0 +1,254 @@ +import * as path from 'path'; +import * as cloudwatch from '@aws-cdk/aws-cloudwatch'; +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as iam from '@aws-cdk/aws-iam'; +import * as ssm from '@aws-cdk/aws-ssm'; +import { + BootstraplessSynthesizer, Construct as CoreConstruct, ConstructNode, + CustomResource, CustomResourceProvider, CustomResourceProviderRuntime, + DefaultStackSynthesizer, IStackSynthesizer, Resource, Stack, StackProps, Stage, Token, +} from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { Alias, AliasOptions } from './alias'; +import { EventInvokeConfigOptions } from './event-invoke-config'; +import { IEventSource } from './event-source'; +import { EventSourceMapping, EventSourceMappingOptions } from './event-source-mapping'; +import { Function, FunctionProps } from './function'; +import { IFunction } from './function-base'; +import { extractQualifierFromArn, IVersion } from './lambda-version'; +import { Permission } from './permission'; + +/** + * Properties for creating a Lambda@Edge function + * @experimental + */ +export interface EdgeFunctionProps extends FunctionProps { } + +/** + * A Lambda@Edge function. + * + * Convenience resource for requesting a Lambda function in the 'us-east-1' region for use with Lambda@Edge. + * Implements several restrictions enforced by Lambda@Edge. + * + * @resource AWS::Lambda::Function + * @experimental + */ +export class EdgeFunction extends Resource implements IVersion { + + private static readonly EDGE_REGION: string = 'us-east-1'; + + public readonly edgeArn: string; + public readonly functionName: string; + public readonly functionArn: string; + public readonly grantPrincipal: iam.IPrincipal; + public readonly isBoundToVpc = false; + public readonly lambda: IFunction; + public readonly permissionsNode: ConstructNode; + public readonly role?: iam.IRole; + public readonly version: string; + + // functionStack and currentVersion needed for `addAlias`. + private readonly functionStack: Stack; + private readonly currentVersion: IVersion; + + constructor(scope: Construct, id: string, props: EdgeFunctionProps) { + super(scope, id); + + // Create a simple Function if we're already in us-east-1; otherwise create a cross-region stack. + const regionIsUsEast1 = !Token.isUnresolved(this.stack.region) && this.stack.region === 'us-east-1'; + const { functionStack, edgeFunction, currentVersion, edgeArn } = regionIsUsEast1 + ? this.createInRegionFunction(id, props) + : this.createCrossRegionFunction(id, props); + + this.functionStack = functionStack; + this.edgeArn = edgeArn; + this.functionArn = edgeArn; + this.currentVersion = currentVersion; + this.lambda = edgeFunction; + this.functionName = this.lambda.functionName; + this.grantPrincipal = this.lambda.role!; + this.permissionsNode = this.lambda.permissionsNode; + this.version = extractQualifierFromArn(this.functionArn); + } + + public addAlias(aliasName: string, options: AliasOptions = {}): Alias { + return new Alias(this.functionStack, `Alias${aliasName}`, { + aliasName, + version: this.currentVersion, + ...options, + }); + } + + /** + * Not supported. Connections are only applicable to VPC-enabled functions. + */ + public get connections(): ec2.Connections { + throw new Error('Lambda@Edge does not support connections'); + } + public get latestVersion(): IVersion { + throw new Error('$LATEST function version cannot be used for Lambda@Edge'); + } + + public addEventSourceMapping(id: string, options: EventSourceMappingOptions): EventSourceMapping { + return this.lambda.addEventSourceMapping(id, options); + } + public addPermission(id: string, permission: Permission): void { + return this.lambda.addPermission(id, permission); + } + public addToRolePolicy(statement: iam.PolicyStatement): void { + return this.lambda.addToRolePolicy(statement); + } + public grantInvoke(identity: iam.IGrantable): iam.Grant { + return this.lambda.grantInvoke(identity); + } + public metric(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.lambda.metric(metricName, { ...props, region: EdgeFunction.EDGE_REGION }); + } + public metricDuration(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.lambda.metricDuration({ ...props, region: EdgeFunction.EDGE_REGION }); + } + public metricErrors(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.lambda.metricErrors({ ...props, region: EdgeFunction.EDGE_REGION }); + } + public metricInvocations(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.lambda.metricInvocations({ ...props, region: EdgeFunction.EDGE_REGION }); + } + public metricThrottles(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.lambda.metricThrottles({ ...props, region: EdgeFunction.EDGE_REGION }); + } + public addEventSource(source: IEventSource): void { + return this.lambda.addEventSource(source); + } + public configureAsyncInvoke(options: EventInvokeConfigOptions): void { + return this.lambda.configureAsyncInvoke(options); + } + + /** Create a function in-region */ + private createInRegionFunction(id: string, props: FunctionProps): FunctionConfig { + const role = props.role ?? defaultLambdaRole(this, id); + const edgeFunction = new Function(this, 'Fn', { + ...props, + role, + }); + const currentVersion = edgeFunction.currentVersion; + + return { edgeFunction, currentVersion, edgeArn: currentVersion.edgeArn, functionStack: this.stack }; + } + + /** Create a support stack and function in us-east-1, and a SSM reader in-region */ + private createCrossRegionFunction(id: string, props: FunctionProps): FunctionConfig { + const parameterName = `EdgeFunctionArn${id}`; + const functionStack = this.edgeStack(); + this.stack.addDependency(functionStack); + + const { edgeFunction, currentVersion } = functionStack.addEdgeFunction(id, parameterName, props); + + const parameterArn = this.stack.formatArn({ + service: 'ssm', + region: EdgeFunction.EDGE_REGION, + resource: 'parameter', + resourceName: parameterName, + }); + + const resourceType = 'Custom::CrossRegionStringParameterReader'; + const serviceToken = CustomResourceProvider.getOrCreate(this, resourceType, { + codeDirectory: path.join(__dirname, 'edge-function'), + runtime: CustomResourceProviderRuntime.NODEJS_12, + policyStatements: [{ + Effect: 'Allow', + Resource: parameterArn, + Action: ['ssm:GetParameter'], + }], + }); + const resource = new CustomResource(this, 'ArnReader', { + resourceType: resourceType, + serviceToken, + properties: { + Region: EdgeFunction.EDGE_REGION, + ParameterName: parameterName, + RefreshEachDeploy: Date.now().toString(), // Ensure this value is refreshed on each deploy, to get the latest function ARN. + }, + }); + const edgeArn = resource.getAttString('FunctionArn'); + + return { edgeFunction, currentVersion, edgeArn, functionStack }; + } + + private edgeStack(): CrossRegionLambdaStack { + const stage = this.node.root; + if (!stage || !Stage.isStage(stage)) { + throw new Error('stacks which use EdgeFunctions must be part of a CDK app or stage'); + } + const region = this.env.region; + if (Token.isUnresolved(region)) { + throw new Error('stacks which use EdgeFunctions must have an explicitly set region'); + } + + const edgeStackId = `edge-lambda-stack-${region}`; + let edgeStack = stage.node.tryFindChild(edgeStackId) as CrossRegionLambdaStack; + if (!edgeStack) { + edgeStack = new CrossRegionLambdaStack(stage, edgeStackId, { + synthesizer: this.getCrossRegionSupportSynthesizer(), + env: { region: EdgeFunction.EDGE_REGION }, + }); + } + return edgeStack; + } + + // Stolen from `@aws-cdk/aws-codepipeline`'s `Pipeline`. + private getCrossRegionSupportSynthesizer(): IStackSynthesizer | undefined { + // If we have the new synthesizer we need a bootstrapless copy of it, + // because we don't want to require bootstrapping the environment + // of the account in this replication region. + // Otheriwse, return undefined to use the default. + return (this.stack.synthesizer instanceof DefaultStackSynthesizer) + ? new BootstraplessSynthesizer({ + deployRoleArn: this.stack.synthesizer.deployRoleArn, + cloudFormationExecutionRoleArn: this.stack.synthesizer.cloudFormationExecutionRoleArn, + }) + : undefined; + } +} + +/** Result of creating an in-region or cross-region function */ +interface FunctionConfig { + readonly edgeFunction: IFunction; + readonly currentVersion: IVersion; + readonly edgeArn: string; + readonly functionStack: Stack; +} + +class CrossRegionLambdaStack extends Stack { + + constructor(scope: CoreConstruct, id: string, props: StackProps) { + super(scope, id, props); + } + + public addEdgeFunction(id: string, parameterName: string, props: FunctionProps) { + const role = props.role ?? defaultLambdaRole(this, id); + + const edgeFunction = new Function(this, id, { + ...props, + role, + }); + const currentVersion = edgeFunction.currentVersion; + + new ssm.StringParameter(edgeFunction, 'Parameter', { + parameterName, + stringValue: currentVersion.edgeArn, + }); + + return { edgeFunction, currentVersion }; + } +} + +function defaultLambdaRole(scope: Construct, id: string): iam.IRole { + return new iam.Role(scope, `${id}ServiceRole`, { + assumedBy: new iam.CompositePrincipal( + new iam.ServicePrincipal('lambda.amazonaws.com'), + new iam.ServicePrincipal('edgelambda.amazonaws.com'), + ), + managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')], + }); +} + diff --git a/packages/@aws-cdk/aws-lambda/lib/edge-function/index.js b/packages/@aws-cdk/aws-lambda/lib/edge-function/index.js new file mode 100644 index 0000000000000..be510adaff5c1 --- /dev/null +++ b/packages/@aws-cdk/aws-lambda/lib/edge-function/index.js @@ -0,0 +1,20 @@ +var AWS = require('aws-sdk'); + +exports.handler = async function (event) { + const props = event.ResourceProperties; + + console.info('Reading function ARN from SSM parameter ' + props.ParameterName + + ' in region ' + props.Region); + + if (event.RequestType === 'Create' || event.RequestType === 'Update') { + const ssm = new AWS.SSM({ region: props.Region }); + const ssmParameter = await ssm.getParameter({ Name: props.ParameterName }).promise(); + console.info('Response: %j', ssmParameter); + const functionArn = ssmParameter.Parameter.Value; + return { + Data: { + FunctionArn: functionArn, + }, + }; + } +}; diff --git a/packages/@aws-cdk/aws-lambda/lib/function-base.ts b/packages/@aws-cdk/aws-lambda/lib/function-base.ts index 7e6c309a3853e..8a21c3ae02d11 100644 --- a/packages/@aws-cdk/aws-lambda/lib/function-base.ts +++ b/packages/@aws-cdk/aws-lambda/lib/function-base.ts @@ -106,6 +106,17 @@ export interface IFunction extends IResource, ec2.IConnectable, iam.IGrantable { */ metricThrottles(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + /** + * Adds an event source to this function. + * + * Event sources are implemented in the @aws-cdk/aws-lambda-event-sources module. + * + * The following example adds an SQS Queue as an event source: + * ``` + * import { SqsEventSource } from '@aws-cdk/aws-lambda-event-sources'; + * myFunction.addEventSource(new SqsEventSource(myQueue)); + * ``` + */ addEventSource(source: IEventSource): void; /** @@ -311,18 +322,6 @@ export abstract class FunctionBase extends Resource implements IFunction { return grant; } - /** - * Adds an event source to this function. - * - * Event sources are implemented in the @aws-cdk/aws-lambda-event-sources module. - * - * The following example adds an SQS Queue as an event source: - * - * import { SqsEventSource } from '@aws-cdk/aws-lambda-event-sources'; - * myFunction.addEventSource(new SqsEventSource(myQueue)); - * - * @param source The event source to bind to this function - */ public addEventSource(source: IEventSource) { source.bind(this); } diff --git a/packages/@aws-cdk/aws-lambda/lib/index.ts b/packages/@aws-cdk/aws-lambda/lib/index.ts index 3581a40cdf535..7f45fd8996bb2 100644 --- a/packages/@aws-cdk/aws-lambda/lib/index.ts +++ b/packages/@aws-cdk/aws-lambda/lib/index.ts @@ -1,5 +1,6 @@ export * from './alias'; export * from './dlq'; +export * from './edge-function'; export * from './function-base'; export * from './function'; export * from './layers'; diff --git a/packages/@aws-cdk/aws-lambda/package.json b/packages/@aws-cdk/aws-lambda/package.json index cd592e5bc3ce6..8fda557b25806 100644 --- a/packages/@aws-cdk/aws-lambda/package.json +++ b/packages/@aws-cdk/aws-lambda/package.json @@ -78,6 +78,7 @@ "@types/aws-lambda": "^8.10.63", "@types/lodash": "^4.14.161", "@types/nodeunit": "^0.0.31", + "aws-sdk": "^2.767.0", "cdk-build-tools": "0.0.0", "cdk-integ-tools": "0.0.0", "cfn2ts": "0.0.0", @@ -97,6 +98,7 @@ "@aws-cdk/aws-s3": "0.0.0", "@aws-cdk/aws-s3-assets": "0.0.0", "@aws-cdk/aws-sqs": "0.0.0", + "@aws-cdk/aws-ssm": "0.0.0", "@aws-cdk/core": "0.0.0", "@aws-cdk/cx-api": "0.0.0", "constructs": "^3.0.4" @@ -114,6 +116,7 @@ "@aws-cdk/aws-s3": "0.0.0", "@aws-cdk/aws-s3-assets": "0.0.0", "@aws-cdk/aws-sqs": "0.0.0", + "@aws-cdk/aws-ssm": "0.0.0", "@aws-cdk/core": "0.0.0", "@aws-cdk/cx-api": "0.0.0", "constructs": "^3.0.4" diff --git a/packages/@aws-cdk/aws-lambda/test/test.edge-function.ts b/packages/@aws-cdk/aws-lambda/test/test.edge-function.ts new file mode 100644 index 0000000000000..34866a4acfa04 --- /dev/null +++ b/packages/@aws-cdk/aws-lambda/test/test.edge-function.ts @@ -0,0 +1,215 @@ +import { countResources, expect, haveResource, haveResourceLike } from '@aws-cdk/assert'; +import * as cloudwatch from '@aws-cdk/aws-cloudwatch'; +import * as iam from '@aws-cdk/aws-iam'; +import { App, Stack } from '@aws-cdk/core'; +import { ICallbackFunction, Test } from 'nodeunit'; +import * as lambda from '../lib'; + +let app: App; +let stack: Stack; + +export = { + setUp: function (cb: ICallbackFunction) { + app = new App(); + stack = new Stack(app, 'Stack', { + env: { account: '111111111111', region: 'testregion' }, + }); + cb(); + }, + + stacks: { + 'creates a custom resource and supporting resources in main stack'(test: Test) { + new lambda.EdgeFunction(stack, 'MyFn', defaultEdgeFunctionProps()); + + expect(stack).to(haveResource('AWS::IAM::Role', { + AssumeRolePolicyDocument: { + Statement: [{ + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { Service: 'lambda.amazonaws.com' }, + }], + Version: '2012-10-17', + }, + ManagedPolicyArns: [ + { 'Fn::Sub': 'arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' }, + ], + Policies: [{ + PolicyName: 'Inline', + PolicyDocument: { + Version: '2012-10-17', + Statement: [{ + Effect: 'Allow', + Resource: { + 'Fn::Join': ['', ['arn:', { Ref: 'AWS::Partition' }, ':ssm:us-east-1:111111111111:parameter/EdgeFunctionArnMyFn']], + }, + Action: ['ssm:GetParameter'], + }], + }, + }], + })); + expect(stack).to(haveResourceLike('AWS::Lambda::Function', { + Handler: '__entrypoint__.handler', + Role: { + 'Fn::GetAtt': ['CustomCrossRegionStringParameterReaderCustomResourceProviderRole71CD6825', 'Arn'], + }, + })); + expect(stack).to(haveResource('Custom::CrossRegionStringParameterReader', { + ServiceToken: { + 'Fn::GetAtt': ['CustomCrossRegionStringParameterReaderCustomResourceProviderHandler65B5F33A', 'Arn'], + }, + Region: 'us-east-1', + ParameterName: 'EdgeFunctionArnMyFn', + })); + + test.done(); + }, + + 'creates the actual function and supporting resources in us-east-1 stack'(test: Test) { + new lambda.EdgeFunction(stack, 'MyFn', defaultEdgeFunctionProps()); + + const fnStack = getFnStack(); + + expect(fnStack).to(haveResource('AWS::IAM::Role', { + AssumeRolePolicyDocument: { + Statement: [{ + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { + Service: [ + 'lambda.amazonaws.com', + 'edgelambda.amazonaws.com', + ], + }, + }], + Version: '2012-10-17', + }, + ManagedPolicyArns: [ + { 'Fn::Join': ['', ['arn:', { Ref: 'AWS::Partition' }, ':iam::aws:policy/service-role/AWSLambdaBasicExecutionRole']] }, + ], + })); + expect(fnStack).to(haveResource('AWS::Lambda::Function', { + Code: { ZipFile: 'foo' }, + Handler: 'index.handler', + Role: { 'Fn::GetAtt': ['MyFnServiceRole10C2021A', 'Arn'] }, + Runtime: 'nodejs12.x', + })); + expect(fnStack).to(haveResource('AWS::Lambda::Version', { + FunctionName: { Ref: 'MyFn6F8F742F' }, + })); + expect(fnStack).to(haveResource('AWS::SSM::Parameter', { + Type: 'String', + Value: { Ref: 'MyFnCurrentVersion309B29FCd8b4ee70a56dc81a87f1bef55d3f737c' }, + Name: 'EdgeFunctionArnMyFn', + })); + + test.done(); + }, + + 'creates minimal constructs if scope region is us-east-1'(test: Test) { + app = new App(); + stack = new Stack(app, 'Stack', { + env: { account: '111111111111', region: 'us-east-1' }, + }); + new lambda.EdgeFunction(stack, 'MyFn', defaultEdgeFunctionProps()); + + expect(stack).to(haveResource('AWS::IAM::Role', { + AssumeRolePolicyDocument: { + Statement: [{ + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { + Service: [ + 'lambda.amazonaws.com', + 'edgelambda.amazonaws.com', + ], + }, + }], + Version: '2012-10-17', + }, + ManagedPolicyArns: [ + { 'Fn::Join': ['', ['arn:', { Ref: 'AWS::Partition' }, ':iam::aws:policy/service-role/AWSLambdaBasicExecutionRole']] }, + ], + })); + expect(stack).to(haveResource('AWS::Lambda::Function', { + Code: { ZipFile: 'foo' }, + Handler: 'index.handler', + Role: { 'Fn::GetAtt': ['MyFnMyFnServiceRole787DF257', 'Arn'] }, + Runtime: 'nodejs12.x', + })); + expect(stack).to(haveResource('AWS::Lambda::Version', { + FunctionName: { Ref: 'MyFn223608AD' }, + })); + + test.done(); + }, + + 'only one cross-region stack is created for multiple functions'(test: Test) { + new lambda.EdgeFunction(stack, 'MyFn1', defaultEdgeFunctionProps()); + new lambda.EdgeFunction(stack, 'MyFn2', defaultEdgeFunctionProps()); + + const fnStack = getFnStack(); + expect(fnStack).to(countResources('AWS::Lambda::Function', 2)); + + test.done(); + }, + }, + + 'addAlias() creates alias in function stack'(test: Test) { + const fn = new lambda.EdgeFunction(stack, 'MyFn', defaultEdgeFunctionProps()); + + fn.addAlias('MyCurrentAlias'); + + const fnStack = getFnStack(); + expect(fnStack).to(haveResourceLike('AWS::Lambda::Alias', { + Name: 'MyCurrentAlias', + })); + + test.done(); + }, + + 'addPermission() creates permissions in function stack'(test: Test) { + const fn = new lambda.EdgeFunction(stack, 'MyFn', defaultEdgeFunctionProps()); + + fn.addPermission('MyPerms', { + action: 'lambda:InvokeFunction', + principal: new iam.AccountPrincipal('123456789012'), + }); + + const fnStack = getFnStack(); + expect(fnStack).to(haveResourceLike('AWS::Lambda::Permission', { + Action: 'lambda:InvokeFunction', + Principal: '123456789012', + })); + + test.done(); + }, + + 'metric methods'(test: Test) { + const fn = new lambda.EdgeFunction(stack, 'MyFn', defaultEdgeFunctionProps()); + + const metrics = new Array(); + metrics.push(fn.metricDuration()); + metrics.push(fn.metricErrors()); + metrics.push(fn.metricInvocations()); + metrics.push(fn.metricThrottles()); + + for (const metric of metrics) { + test.equals(metric.namespace, 'AWS/Lambda'); + test.equals(metric.region, 'us-east-1'); + } + + test.done(); + }, +}; + +function defaultEdgeFunctionProps() { + return { + code: lambda.Code.fromInline('foo'), + handler: 'index.handler', + runtime: lambda.Runtime.NODEJS_12_X, + }; +} + +function getFnStack(region: string = 'testregion'): Stack { + return app.node.findChild(`edge-lambda-stack-${region}`) as Stack; +}