-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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. 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
Showing
5 changed files
with
309 additions
and
12 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | ||
} | ||
} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
80 changes: 80 additions & 0 deletions
80
packages/@aws-cdk/aws-cloudfront/test/edge-function.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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!', | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters