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.

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
  • Loading branch information
njlynch committed Oct 6, 2020
1 parent 063798b commit cb7d1be
Show file tree
Hide file tree
Showing 7 changed files with 507 additions and 13 deletions.
4 changes: 3 additions & 1 deletion packages/@aws-cdk/aws-lambda/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ nyc.config.js
*.snk
!.eslintrc.js

junit.xml
!lib/edge-function/index.js

junit.xml
254 changes: 254 additions & 0 deletions packages/@aws-cdk/aws-lambda/lib/edge-function.ts
Original file line number Diff line number Diff line change
@@ -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')],
});
}

20 changes: 20 additions & 0 deletions packages/@aws-cdk/aws-lambda/lib/edge-function/index.js
Original file line number Diff line number Diff line change
@@ -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,
},
};
}
};
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
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-lambda/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './alias';
export * from './dlq';
export * from './edge-function';
export * from './function-base';
export * from './function';
export * from './layers';
Expand Down
3 changes: 3 additions & 0 deletions packages/@aws-cdk/aws-lambda/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand All @@ -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"
Expand Down
Loading

0 comments on commit cb7d1be

Please sign in to comment.