Recently got an idea to organize a PR preview in github for my frontend code utilising S3 and CloudFront capabilites. I've found a nice article describing the basics. But since we are using configuration as a code approach - that was quite a good task to finally try a new AWS CDK tool to work with CloudFormation (version 1.19.0).
NOTE: article was updated to use version 1.19.0 which is stable instead of 0.28.0
What can I say right now - I've really enjoyed to write a code with it. With the GA it introduced even create-change-set only behaviour, which is great for the secure deployments with supervision.
First of all I've tried to find a ready-to-use example of Lambda@Edge and found only issue. Explanations didn't help much, so I've moved into discovery on how to achieve my goal. With multiple sources I've found that the only working way is to publish somehow a version, then get that and pass into CloudFront.
The main issue which made my case quite more complex than examples was mainly that I want to have bucket in a different
region than us-east-1
, so I need to pass the lambda version somehow to another region.
Of all required things:
cdk.js (Definitions)
const cdk = require('@aws-cdk/core');
const lambda = require('@aws-cdk/aws-lambda');
const s3 = require('@aws-cdk/aws-s3');
const cfr = require('@aws-cdk/aws-cloudfront');
const iam = require('@aws-cdk/aws-iam');
const cfn = require('@aws-cdk/aws-cloudformation');
const r53 = require('@aws-cdk/aws-route53');
const cr = require('@aws-cdk/custom-resources');
const sha256 = require('sha256-file');
// https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/quickref-route53.html
const CF_HOSTED_ZONE_ID = 'Z2FDTNDATAQYW2';
// To share between stacks easily
const LAMBDA_OUTPUT_NAME = 'LambdaOutput';
const LAMBDA_EDGE_STACK_NAME = 'stack-name';
// Will be used as *.domain to handle pr preview requests
const DOMAIN_NAME = 'example.com';
// This cert should be created in us-east-1!
const CERTIFICATE_ARN = 'arn:aws:acm:us-east-1:<aid>:certificate/<cert>';
const app = new cdk.App();
so we need bunch of services involved and also something to get a hash of a file to update a version when its really needed.
Then the edge lambda stack itself. Quite similar to any CloudFormation/AWS CDK examples:
cdk.js (Edge Lambda stack)
class LambdaStack extends cdk.Stack {
constructor(parent, id, props) {
super(parent, id, props);
const override = new lambda.Function(this, 'your-lambda', {
runtime: lambda.Runtime.NODEJS_10_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('./lambda'),
role: new iam.Role(this, 'AllowLambdaServiceToAssumeRole', {
assumedBy: new iam.CompositePrincipal(
new iam.ServicePrincipal('lambda.amazonaws.com'),
new iam.ServicePrincipal('edgelambda.amazonaws.com'),
),
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')]
})
});
// this way it updates version only in case lambda code changes
const version = override.addVersion(':sha256:' + sha256('./lambda/index.js'));
// the main magic to easily pass the lambda version to stack in another region
// this output is required
new cdk.CfnOutput(this, LAMBDA_OUTPUT_NAME, {
value: cdk.Fn.join(":", [
override.functionArn,
version.version
])
});
}
}
It could be way less verbosive in case built-in hight-level api of aws cdk will support edge lamdas. This stack definition contains the CloudFront definition itself, S3 bucket to serve static files from and also a custom resource lambda to fetch the edge lambda version
cdk.js (CloudFront)
class StaticSiteStack extends cdk.Stack {
constructor(parent, id, props) {
super(parent, id, props);
/*
* Custom resource lambda to query the edge lambda stack
*/
const lambdaProvider = new lambda.SingletonFunction(this, 'Provider', {
/*
to avoid multiple lambda deployments
in case we will use that custom resource multiple times
*/
uuid: 'f7d4f730-4ee1-11e8-9c2d-fa7ae01bbebc',
code: lambda.Code.fromAsset('./cfn'),
handler: 'stack.handler',
timeout: cdk.Duration.seconds(60),
runtime: lambda.Runtime.NODEJS_10_X,
});
/*
To allow aws sdk call inside the lambda
Such a nice API!
*/
lambdaProvider.addToRolePolicy(
/*
obviously you will need another policy
in case you will choose another way to query the version
*/
new iam.PolicyStatement({
actions: ['cloudformation:DescribeStacks'],
resources: [`arn:aws:cloudformation:*:*:stack/${LAMBDA_EDGE_STACK_NAME}/*`]
})
);
// This basically goes to another region to edge stack and grabs the version output
const stackOutput = new cfn.CustomResource(this, 'StackOutput', {
provider: new cr.Provider(this, 'StackOutputProvider', {
onEventHandler: lambdaProvider
}),
properties: {
StackName: LAMBDA_EDGE_STACK_NAME,
OutputKey: LAMBDA_OUTPUT_NAME,
/* just to change custom resource on code update */
LambdaHash: sha256('./lambda/index.js')
}
});
// Here we will upload our website
const bucket = new s3.Bucket(this, 'bucket', {
publicReadAccess: true // not really sure I need this permission actually
});
const origin = {
domainName: bucket.bucketDomainName,
id: 'origin1',
s3OriginConfig: {}
};
// CloudFrontWebDistribution will simplify a lot,
// but it doesn't support lambdaFunctionAssociations in any way right now :(
const distribution = new cfr.CfnDistribution(this, 'WebSiteDistribution', {
distributionConfig: {
aliases: ['site.' + DOMAIN_NAME, '*.site.' + DOMAIN_NAME],
defaultCacheBehavior: {
allowedMethods: ['GET', 'HEAD'],
cachedMethods: ['GET', 'HEAD'],
defaultTtl: 60,
maxTtl: 60,
targetOriginId: origin.id,
viewerProtocolPolicy: cfr.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
forwardedValues: {
cookies: {
forward: 'none'
},
queryString: false
},
lambdaFunctionAssociations: [
{
eventType: 'viewer-request',
lambdaFunctionArn: stackOutput.getAttString('Output')
}
]
},
defaultRootObject: 'index.html',
enabled: true,
httpVersion: cfr.HttpVersion.HTTP2,
origins: [
origin
],
priceClass: cfr.PriceClass.PRICE_CLASS_100,
viewerCertificate: {
acmCertificateArn: CERTIFICATE_ARN,
sslSupportMethod: cfr.SSLMethod.SNI
}
},
tags: [{
key: 'stack',
value: this.stackId
}]
});
const zone = r53.HostedZone.fromLookup(this, 'MyPublicZone', {
domainName: DOMAIN_NAME
});
new r53.ARecord(this, 'BaseRecord', {
recordName: 'site', // Meaningful part only, ommiting DOMAIN_NAME
zone: zone,
target: r53.RecordTarget.fromAlias({
bind: () => ({
hostedZoneId: CF_HOSTED_ZONE_ID,
dnsName: distribution.attrDomainName
})
})
});
new r53.ARecord(this, 'StarRecord', {
recordName: '*.site',
zone: zone,
target: r53.RecordTarget.fromAlias({
bind: () => ({
hostedZoneId: CF_HOSTED_ZONE_ID,
dnsName: distribution.attrDomainName
})
})
});
// Bunch of outputs to see everything manually
new cdk.CfnOutput(this, 'Bucket', {
value: `s3://${bucket.bucketName}`
});
new cdk.CfnOutput(this, 'CfDomain', {
value: distribution.attrDomainName
});
new cdk.CfnOutput(this, 'CfId', {
value: distribution.distributionId
});
// to reverify it was really updated to a proper version
new cdk.CfnOutput(this, 'LambdaEdge', {
value: stackOutput.getAttString('Output')
});
}
}
cdk.js (Stack creation)
const ls = new LambdaStack(app, LAMBDA_EDGE_STACK_NAME, {
env: {
region: 'us-east-1' // note that edge can be deployed only here
}
});
new StaticSiteStack(app, 'cf-stack', {
// to properly lookup the hosted zone
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
}).addDependency(ls);
Custom resource lambda which will grab the output
/cfn/stack.js (Custom resource)
exports.handler = (event, context) => {
console.log("REQUEST RECEIVED:\n" + JSON.stringify(event));
const aws = require("aws-sdk");
const response = require('cfn-response');
const {RequestType, ResourceProperties: {StackName, OutputKey}} = event;
if (RequestType === 'Delete') {
return response.send(event, context, response.SUCCESS);
}
const cfn = new aws.CloudFormation({region: 'us-east-1'});
cfn.describeStacks({StackName}, (err, {Stacks}) => {
if (err) {
console.log("Error during stack describe:\n", err);
return response.send(event, context, response.FAILED, err);
}
const Output = Stacks[0].Outputs
.filter(out => out.OutputKey === OutputKey)
.map(out => out.OutputValue)
.join();
response.send(event, context, response.SUCCESS, {Output});
});
};
don't forget to add /cfn/cfn-response.js
file with a content listed here:
NOTE: for some reason I wasn't able to resolve the file properly as a separate
cfn-response
module, so replaced it withresponse = { ... }
object before the lambda
We can create fake lambda with debug output first
/lambda/index.js (Edge lambda)
exports.handler = (event, context, callback) => {
console.log("REQUEST", JSON.stringify(event));
const status = '200';
const headers = {
'content-type': [{
key: 'Content-Type',
value: 'application/json'
}]
};
const body = JSON.stringify(event, null, 2);
return callback(null, {status, headers, body});
};
The real lambda btw looks something like this:
exports.handler = (event, context, callback) => {
console.log("REQUEST", JSON.stringify(event));
const {request} = event.Records[0].cf;
const {host} = request.headers;
if (host && host.length) {
const [subdomain] = host[0].value.split(".");
if (subdomain) {
const [number, ...service] = subdomain.split('-');
if (number && service) {
const path = require('path');
if (!path.extname(request.uri)) {
request.uri = '/index.html';
}
request.uri = `/preview/${service.join("-")}/${number}${request.uri}`;
}
return callback(null, request);
}
}
callback("Missing Host header");
};