Skip to content

Commit

Permalink
feat(cloudfront): Lambda@Edge construct
Browse files Browse the repository at this point in the history
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.

DRAFT -- What exists does work and has been successfully deployed with a
CloudFront distribution based in eu-west-1, but all of the normal Lambda
functionality (e.g., permissions) has not been verified to work yet.

Some open questions:
1. Where does this belong? There are circular dependency issues with
  `custom-resources` that prevents this from being in `aws-lambda`.
  `aws-cloudfront` seems possibly the next-best thing, but I can also see an
  argument for moving this into its own module.
2. Does this approach (creating a new stack and using SSM + an AWS custom
  resource) make sense, or should another approach be taken?
3. I intentionally didn't extend from `FunctionBase` in order to override most
  of the methods there to redirect to the delegate function; it's not clear (yet)
  that this will work and/or is a good idea. Feedback welcome.

Thanks to @asterikx for the inspiration and consolidated writeup in #1575.

fixes #9862
  • Loading branch information
njlynch committed Sep 23, 2020
1 parent c179699 commit 06b75c6
Show file tree
Hide file tree
Showing 5 changed files with 309 additions and 12 deletions.
209 changes: 209 additions & 0 deletions packages/@aws-cdk/aws-cloudfront/lib/edge-function.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
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 lambda from '@aws-cdk/aws-lambda';
import * as ssm from '@aws-cdk/aws-ssm';
import {
App, BootstraplessSynthesizer, Construct, ConstructNode, DefaultStackSynthesizer, IStackSynthesizer,
ResourceEnvironment, Stack, StackProps, Token,
} from '@aws-cdk/core';
import * as resources from '@aws-cdk/custom-resources';

/** Properties for creating a Lambda@Edge function */
export interface EdgeFunctionProps extends lambda.FunctionProps { }

/**
* A Lambda@Edge function.
*
* Convenience construct for requesting a Lambda function in the 'us-east-1' region for use with Lambda@Edge.
* Implements several restrictions enforced by Lambda@Edge.
*/
export class EdgeFunction extends Construct implements lambda.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: lambda.IFunction;
public readonly permissionsNode: ConstructNode;
public readonly role?: iam.IRole;
public readonly version: string;

public readonly stack: Stack;
public readonly env: ResourceEnvironment;

private readonly functionStack: CrossRegionLambdaStack;
private readonly currentVersion: lambda.IVersion;

constructor(scope: Construct, id: string, props: EdgeFunctionProps) {
super(scope, id);

this.stack = Stack.of(this);
this.env = {
account: this.stack.account,
region: this.stack.region,
};

this.functionStack = this.getOrCreateEdgeStack();
this.stack.addDependency(this.functionStack);

const parameterName = `EdgeFunctionArn-${id}`;
const { edgeFunction, currentVersion } = this.functionStack.addEdgeFunction(id, parameterName, props);
this.currentVersion = currentVersion;

const parameterArn = this.stack.formatArn({
service: 'ssm',
region: EdgeFunction.EDGE_REGION,
resource: 'parameter',
resourceName: parameterName,
});

const customResource = new resources.AwsCustomResource(scope, `${id}FunctionArnReader`, {
policy: resources.AwsCustomResourcePolicy.fromStatements([
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['ssm:GetParameter'],
resources: [parameterArn],
}),
]),
onUpdate: {
service: 'SSM',
action: 'getParameter',
parameters: { Name: parameterName },
region: EdgeFunction.EDGE_REGION,
// Update physical id to always fetch the latest version
physicalResourceId: resources.PhysicalResourceId.of(Date.now().toString()),
},
});

this.functionArn = customResource.getResponseField('Parameter.Value');
// this.lambda = lambda.Function.fromFunctionArn(this, 'ImportedFn', this.functionArn);
this.lambda = edgeFunction;
this.functionName = this.lambda.functionName;
this.grantPrincipal = this.lambda.role!; // TODO - Does this work, or does it need to be imported?
this.permissionsNode = this.lambda.permissionsNode;
this.edgeArn = this.functionArn;
this.version = lambda.extractQualifierFromArn(this.functionArn);
}

/**
* 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(): lambda.IVersion {
throw new Error('$LATEST function version cannot be used for Lambda@Edge');
}

public addEventSourceMapping(id: string, options: lambda.EventSourceMappingOptions): lambda.EventSourceMapping {
return this.lambda.addEventSourceMapping(id, options);
}
public addPermission(id: string, permission: lambda.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: lambda.IEventSource): void {
return this.lambda.addEventSource(source);
}
public configureAsyncInvoke(options: lambda.EventInvokeConfigOptions): void {
return this.lambda.configureAsyncInvoke(options);
}

public addAlias(aliasName: string, options: lambda.AliasOptions = {}): lambda.Alias {
return new lambda.Alias(this.functionStack, `Alias${aliasName}`, {
aliasName,
version: this.currentVersion,
...options,
});
}

private getOrCreateEdgeStack(): CrossRegionLambdaStack {
const app = this.node.root;
if (!app || !App.isApp(app)) {
throw new Error('Stacks which use EdgeFunctions must be part of a CDK app');
}
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 = app.node.tryFindChild(edgeStackId) as CrossRegionLambdaStack;
if (!edgeStack) {
edgeStack = new CrossRegionLambdaStack(this, 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;
}
}

class CrossRegionLambdaStack extends Stack {

constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
}

public addEdgeFunction(id: string, parameterName: string, props: lambda.FunctionProps) {
const role = props.role || new iam.Role(this, `${id}FnServiceRole`, {
assumedBy: new iam.CompositePrincipal(
new iam.ServicePrincipal('lambda.amazonaws.com'),
new iam.ServicePrincipal('edgelambda.amazonaws.com'),
),
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')],
});

const edgeFunction = new lambda.Function(this, `${id}Fn`, {
...props,
role,
});
const currentVersion = edgeFunction.currentVersion;

new ssm.StringParameter(this, `${id}ArnParameter`, {
parameterName,
stringValue: currentVersion.functionArn,
});

return { edgeFunction, currentVersion };
}
}

1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-cloudfront/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './distribution';
export * from './edge-function';
export * from './geo-restriction';
export * from './origin';
export * from './origin_access_identity';
Expand Down
8 changes: 8 additions & 0 deletions packages/@aws-cdk/aws-cloudfront/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,21 +74,29 @@
},
"dependencies": {
"@aws-cdk/aws-certificatemanager": "0.0.0",
"@aws-cdk/aws-cloudwatch": "0.0.0",
"@aws-cdk/aws-ec2": "0.0.0",
"@aws-cdk/aws-iam": "0.0.0",
"@aws-cdk/aws-kms": "0.0.0",
"@aws-cdk/aws-lambda": "0.0.0",
"@aws-cdk/aws-s3": "0.0.0",
"@aws-cdk/aws-ssm": "0.0.0",
"@aws-cdk/core": "0.0.0",
"@aws-cdk/custom-resources": "0.0.0",
"constructs": "^3.0.4"
},
"homepage": "https://github.com/aws/aws-cdk",
"peerDependencies": {
"@aws-cdk/aws-certificatemanager": "0.0.0",
"@aws-cdk/aws-cloudwatch": "0.0.0",
"@aws-cdk/aws-ec2": "0.0.0",
"@aws-cdk/aws-iam": "0.0.0",
"@aws-cdk/aws-kms": "0.0.0",
"@aws-cdk/aws-lambda": "0.0.0",
"@aws-cdk/aws-s3": "0.0.0",
"@aws-cdk/aws-ssm": "0.0.0",
"@aws-cdk/core": "0.0.0",
"@aws-cdk/custom-resources": "0.0.0",
"constructs": "^3.0.4"
},
"engines": {
Expand Down
80 changes: 80 additions & 0 deletions packages/@aws-cdk/aws-cloudfront/test/edge-function.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import '@aws-cdk/assert/jest';
import * as lambda from '@aws-cdk/aws-lambda';
import { App, Stack } from '@aws-cdk/core';
import { EdgeFunction } from '../lib';

let app: App;
let stack: Stack;

beforeEach(() => {
app = new App();
stack = new Stack(app, 'Stack', {
env: { account: '111111111111', region: 'testregion' },
});
});

describe('stacks', () => {
test('creates a custom resource and supporting resources in main stack', () => {
new EdgeFunction(stack, 'MyFn', defaultEdgeFunctionProps());

expect(stack).toHaveResource('AWS::IAM::Policy', {
PolicyDocument: {
Statement: [{
Action: 'ssm:GetParameter',
Effect: 'Allow',
Resource:
{
'Fn::Join': ['', ['arn:', { Ref: 'AWS::Partition' }, ':ssm:us-east-1:111111111111:parameter/EdgeFunctionArn-MyFn']],
},
}],
Version: '2012-10-17',
},
});
expect(stack).toHaveResource('AWS::IAM::Role', {
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']] },
],
});
expect(stack).toHaveResource('Custom::AWS', {
ServiceToken: { 'Fn::GetAtt': ['AWS679f53fac002430cb0da5b7982bd22872D164C4C', 'Arn'] },
Create: {
service: 'SSM',
action: 'getParameter',
parameters: { Name: 'EdgeFunctionArn-MyFn' },
region: 'us-east-1',
physicalResourceId: { id: '1600862714533' },
},
Update: {
service: 'SSM',
action: 'getParameter',
parameters: { Name: 'EdgeFunctionArn-MyFn' },
region: 'us-east-1',
physicalResourceId: { id: '1600862714533' },
},
InstallLatestAwsSdk: true,
});
});
});

function defaultEdgeFunctionProps() {
return {
code: lambda.Code.fromInline(`exports.handler = ${helloCode}`),
handler: 'index.handler',
runtime: lambda.Runtime.NODEJS_12_X,
};
}

function helloCode(_event: any, _context: any, callback: any) {
return callback(undefined, {
statusCode: 200,
body: 'hello, world!',
});
}
23 changes: 11 additions & 12 deletions packages/@aws-cdk/aws-lambda/lib/function-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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);
}
Expand Down

0 comments on commit 06b75c6

Please sign in to comment.