AWS/CloudFormation
From r00tedvw.com wiki
(Difference between revisions)
(3 intermediate revisions by one user not shown) | |||
Line 1: | Line 1: | ||
[[AWS/Quick_Reference|Quick Reference]] | [[AWS/CLI|AWS CLI]] | [[AWS/CloudFormation|CloudFormation]] | [[AWS/Quick_Reference|Quick Reference]] | [[AWS/CLI|AWS CLI]] | [[AWS/CloudFormation|CloudFormation]] | ||
+ | =AWS Cloudformation CLI= | ||
+ | ==Testing template== | ||
+ | [https://docs.aws.amazon.com/cli/latest/reference/cloudformation/validate-template.html https://docs.aws.amazon.com/cli/latest/reference/cloudformation/validate-template.html] | ||
+ | <nowiki>~$ aws cloudformation validate-template --template-body file://$HOME/s3_ec2.yaml | ||
+ | { | ||
+ | "CapabilitiesReason": "The following resource(s) require capabilities: [AWS::IAM::Policy]", | ||
+ | "Capabilities": [ | ||
+ | "CAPABILITY_IAM" | ||
+ | ], | ||
+ | "Parameters": [ | ||
+ | { | ||
+ | "DefaultValue": "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2", | ||
+ | "NoEcho": false, | ||
+ | "ParameterKey": "LatestAmiId" | ||
+ | }, | ||
+ | { | ||
+ | "NoEcho": false, | ||
+ | "Description": "Random String", | ||
+ | "ParameterKey": "Entropy" | ||
+ | } | ||
+ | ] | ||
+ | }</nowiki> | ||
+ | |||
+ | ==Creating stack== | ||
+ | [https://docs.aws.amazon.com/cli/latest/reference/cloudformation/create-stack.html https://docs.aws.amazon.com/cli/latest/reference/cloudformation/create-stack.html] | ||
+ | <nowiki>~$ aws cloudformation create-stack --stack-name s3ec2stack01 --template-body file://$HOME/s3_ec2.yaml --parameters ParameterKey=Entropy,ParameterValue=testentropy ParameterKey=BranchName,ParameterValue=testbranch --capabilities CAPABILITY_IAM | ||
+ | { | ||
+ | "StackId": "arn:aws:cloudformation:us-east-1:548975612458:stack/s3ec2stack01/177a94e0-9923-23et-b86a-0a5462e42a87" | ||
+ | }</nowiki> | ||
+ | |||
=Template Examples= | =Template Examples= | ||
==S3 Bucket== | ==S3 Bucket== | ||
Line 7: | Line 37: | ||
*Bucket Policy allowing access to all resources. | *Bucket Policy allowing access to all resources. | ||
*Export the S3 bucket name, Secure URL, and Website URL for usage in other templates. | *Export the S3 bucket name, Secure URL, and Website URL for usage in other templates. | ||
+ | <div class="toccolours mw-collapsible mw-collapsed"> | ||
+ | AWS Template: | ||
+ | <div class="mw-collapsible-content"> | ||
<nowiki>AWSTemplateFormatVersion: 2010-09-09 | <nowiki>AWSTemplateFormatVersion: 2010-09-09 | ||
Line 65: | Line 98: | ||
Export: | Export: | ||
Name: !Sub "${AWS::StackName}-S3WebsiteURL"</nowiki> | Name: !Sub "${AWS::StackName}-S3WebsiteURL"</nowiki> | ||
+ | </div> | ||
+ | </div> | ||
+ | ==Lambda & CloudFront== | ||
+ | Here is a CloudFormation Template (in yaml) that I used to create a 2 Lambda functions and a cloudfront distribution with the following requirements: | ||
+ | *use outputs from S3 bucket stack | ||
+ | *create lambda functions that will work with lambda edge. | ||
+ | :*setup the needed IAM roles for lambda functions to execute on lambda edge. | ||
+ | *create cloudfront distribution that uses the lambda edge functions and configures the needed behaviors to use them. | ||
+ | |||
+ | <div class="toccolours mw-collapsible mw-collapsed"> | ||
+ | AWS Template: | ||
+ | <div class="mw-collapsible-content"> | ||
+ | <nowiki>AWSTemplateFormatVersion: 2010-09-09 | ||
+ | |||
+ | Parameters: | ||
+ | BranchName: | ||
+ | Description: Branch Name | ||
+ | Type: String | ||
+ | S3BucketStack: | ||
+ | Description: S3 Bucket Stack Name | ||
+ | Type: String | ||
+ | Default: SYNC-s3-dev-ui-stack | ||
+ | Entropy: | ||
+ | Description: Random String | ||
+ | Type: String | ||
+ | |||
+ | Resources: | ||
+ | |||
+ | LambdaEdgeRoleRequest: | ||
+ | Type: AWS::IAM::Role | ||
+ | Properties: | ||
+ | AssumeRolePolicyDocument: | ||
+ | Version: '2012-10-17' | ||
+ | Statement: | ||
+ | - Effect: Allow | ||
+ | Principal: | ||
+ | Service: | ||
+ | - lambda.amazonaws.com | ||
+ | - edgelambda.amazonaws.com | ||
+ | Action: sts:AssumeRole | ||
+ | Path: "/service-role/" | ||
+ | RoleName: | ||
+ | Fn::Sub: 'LambaEdgeRoleRequest-${BranchName}-${Entropy}' | ||
+ | Policies: | ||
+ | - PolicyName: | ||
+ | Fn::Sub: 'AWSLambdaBasicExecutionRole-OriginRequest-${Entropy}' | ||
+ | PolicyDocument: | ||
+ | Version: 2012-10-17 | ||
+ | Statement: | ||
+ | - Effect: "Allow" | ||
+ | Action: | ||
+ | - "logs:CreateLogGroup" | ||
+ | Resource: | ||
+ | Fn::Sub: "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*" | ||
+ | - Effect: "Allow" | ||
+ | Action: | ||
+ | - "logs:CreateLogStream" | ||
+ | - "logs:PutLogEvents" | ||
+ | Resource: | ||
+ | Fn::Sub: "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/SyncUI-${BranchName}-OriginRequest-${Entropy}:*" | ||
+ | |||
+ | LambdaFunctionRequest: | ||
+ | Type: AWS::Lambda::Function | ||
+ | DependsOn: LambdaEdgeRoleRequest | ||
+ | DeletionPolicy: Retain | ||
+ | Properties: | ||
+ | Handler: "index.handler" | ||
+ | Role: | ||
+ | Fn::GetAtt: | ||
+ | - "LambdaEdgeRoleRequest" | ||
+ | - "Arn" | ||
+ | Runtime: "nodejs10.x" | ||
+ | Code: | ||
+ | S3Bucket: | ||
+ | Fn::ImportValue: | ||
+ | !Sub ${S3BucketStack}-S3BucketName | ||
+ | S3Key: !Sub "${BranchName}/OriginRequest.zip" | ||
+ | FunctionName: | ||
+ | Fn::Sub: 'SyncUI-${BranchName}-OriginRequest-${Entropy}' | ||
+ | |||
+ | LambdaFunctionRequestVersion: | ||
+ | Type: AWS::Lambda::Version | ||
+ | Properties: | ||
+ | FunctionName: | ||
+ | Ref: "LambdaFunctionRequest" | ||
+ | |||
+ | LambdaEdgeRoleResponse: | ||
+ | Type: AWS::IAM::Role | ||
+ | Properties: | ||
+ | AssumeRolePolicyDocument: | ||
+ | Version: '2012-10-17' | ||
+ | Statement: | ||
+ | - Effect: Allow | ||
+ | Principal: | ||
+ | Service: | ||
+ | - lambda.amazonaws.com | ||
+ | - edgelambda.amazonaws.com | ||
+ | Action: sts:AssumeRole | ||
+ | Path: "/service-role/" | ||
+ | RoleName: | ||
+ | Fn::Sub: 'LambaEdgeRoleResponse-${BranchName}-${Entropy}' | ||
+ | Policies: | ||
+ | - PolicyName: | ||
+ | Fn::Sub: 'AWSLambdaBasicExecutionRole-OriginResponse-${Entropy}' | ||
+ | PolicyDocument: | ||
+ | Version: 2012-10-17 | ||
+ | Statement: | ||
+ | - Effect: "Allow" | ||
+ | Action: | ||
+ | - "logs:CreateLogGroup" | ||
+ | Resource: | ||
+ | Fn::Sub: "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*" | ||
+ | - Effect: "Allow" | ||
+ | Action: | ||
+ | - "logs:CreateLogStream" | ||
+ | - "logs:PutLogEvents" | ||
+ | Resource: | ||
+ | Fn::Sub: "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/SyncUI-${BranchName}-OriginResponse-${Entropy}:*" | ||
+ | |||
+ | LambdaFunctionResponse: | ||
+ | Type: AWS::Lambda::Function | ||
+ | DependsOn: LambdaEdgeRoleResponse | ||
+ | DeletionPolicy: Retain | ||
+ | Properties: | ||
+ | Handler: "index.handler" | ||
+ | Role: | ||
+ | Fn::GetAtt: | ||
+ | - "LambdaEdgeRoleResponse" | ||
+ | - "Arn" | ||
+ | Runtime: "nodejs10.x" | ||
+ | Code: | ||
+ | S3Bucket: | ||
+ | Fn::ImportValue: | ||
+ | !Sub ${S3BucketStack}-S3BucketName | ||
+ | S3Key: !Sub "${BranchName}/OriginResponse.zip" | ||
+ | FunctionName: | ||
+ | Fn::Sub: 'SyncUI-${BranchName}-OriginResponse-${Entropy}' | ||
+ | |||
+ | LambdaFunctionResponseVersion: | ||
+ | Type: AWS::Lambda::Version | ||
+ | Properties: | ||
+ | FunctionName: | ||
+ | Ref: "LambdaFunctionResponse" | ||
+ | |||
+ | CloudFrontDistribution: | ||
+ | Type: AWS::CloudFront::Distribution | ||
+ | DependsOn: | ||
+ | - LambdaFunctionRequest | ||
+ | - LambdaFunctionResponse | ||
+ | Properties: | ||
+ | DistributionConfig: | ||
+ | Enabled: 'true' | ||
+ | Origins: | ||
+ | - DomainName: | ||
+ | Fn::ImportValue: | ||
+ | !Sub "${S3BucketStack}-S3WebsiteURL" | ||
+ | OriginPath: | ||
+ | Fn::Sub: '/${BranchName}' | ||
+ | CustomOriginConfig: | ||
+ | OriginProtocolPolicy: match-viewer | ||
+ | Id: !Sub "CFID-${BranchName}-${Entropy}" | ||
+ | DefaultCacheBehavior: | ||
+ | TargetOriginId: !Sub "CFID-${BranchName}-${Entropy}" | ||
+ | ForwardedValues: | ||
+ | QueryString: 'false' | ||
+ | ViewerProtocolPolicy: allow-all | ||
+ | LambdaFunctionAssociations: | ||
+ | - EventType: origin-request | ||
+ | LambdaFunctionARN: !Ref LambdaFunctionRequestVersion | ||
+ | - EventType: origin-response | ||
+ | LambdaFunctionARN: !Ref LambdaFunctionResponseVersion | ||
+ | |||
+ | CacheBehaviors: | ||
+ | - PathPattern: | ||
+ | Fn::Sub: '/${BranchName}' | ||
+ | ForwardedValues: | ||
+ | QueryString: false | ||
+ | TargetOriginId: !Sub "CFID-${BranchName}-${Entropy}" | ||
+ | ViewerProtocolPolicy: allow-all | ||
+ | LambdaFunctionAssociations: | ||
+ | - EventType: origin-request | ||
+ | LambdaFunctionARN: !Ref LambdaFunctionRequestVersion | ||
+ | - EventType: origin-response | ||
+ | LambdaFunctionARN: !Ref LambdaFunctionResponseVersion | ||
+ | - PathPattern: | ||
+ | Fn::Sub: '/${BranchName}/*' | ||
+ | ForwardedValues: | ||
+ | QueryString: false | ||
+ | TargetOriginId: !Sub "CFID-${BranchName}-${Entropy}" | ||
+ | ViewerProtocolPolicy: allow-all | ||
+ | LambdaFunctionAssociations: | ||
+ | - EventType: origin-request | ||
+ | LambdaFunctionARN: !Ref LambdaFunctionRequestVersion | ||
+ | - EventType: origin-response | ||
+ | LambdaFunctionARN: !Ref LambdaFunctionResponseVersion</nowiki> | ||
+ | </div> | ||
+ | </div> | ||
+ | |||
+ | <div class="toccolours mw-collapsible mw-collapsed"> | ||
+ | OriginRequest.js | ||
+ | <div class="mw-collapsible-content"> | ||
+ | <nowiki>'use strict'; | ||
+ | |||
+ | const pointsToFile = uri => /\/[^/]+\.[^/]+$/.test(uri); | ||
+ | const appName = '{branchname}'; | ||
+ | |||
+ | exports.handler = (event, context, callback) => { | ||
+ | |||
+ | console.log(JSON.stringify(event, null, 2)); | ||
+ | |||
+ | // Extract the request from the CloudFront event that is sent to Lambda@Edge | ||
+ | const request = event.Records[0].cf.request; | ||
+ | |||
+ | // Extract the URI from the request | ||
+ | const oldUri = request.uri; | ||
+ | let newUri; | ||
+ | |||
+ | if (!pointsToFile(oldUri) && !oldUri.endsWith('/')) { | ||
+ | |||
+ | newUri = request.querystring ? `${oldUri}/?${request.querystring}` : `${oldUri}/`; | ||
+ | |||
+ | console.log('new uri for path: ' + newUri); | ||
+ | return callback(null, { | ||
+ | body: '', | ||
+ | status: '301', | ||
+ | statusDescription: 'Moved Permanently', | ||
+ | headers: { | ||
+ | location: [{ | ||
+ | key: 'Location', | ||
+ | value: newUri | ||
+ | }], | ||
+ | } | ||
+ | }); | ||
+ | } else { | ||
+ | newUri = oldUri; | ||
+ | } | ||
+ | |||
+ | // Match any '/' that occurs at the end of a URI. Replace it with a default index | ||
+ | newUri = newUri.replace(`/${appName}`, ''); | ||
+ | |||
+ | // Log the URI as received by CloudFront and the new URI to be used to fetch from origin | ||
+ | console.log("Original URI: " + oldUri); | ||
+ | console.log("Updated URI: " + newUri); | ||
+ | |||
+ | // Replace the received URI with the URI that includes the index page | ||
+ | request.uri = newUri; | ||
+ | |||
+ | // Return to CloudFront | ||
+ | return callback(null, request); | ||
+ | };</nowiki> | ||
+ | </div> | ||
+ | </div> | ||
+ | |||
+ | <div class="toccolours mw-collapsible mw-collapsed"> | ||
+ | OriginResponse.js | ||
+ | <div class="mw-collapsible-content"> | ||
+ | <nowiki>'use strict'; | ||
+ | |||
+ | const http = require('https'); | ||
+ | |||
+ | const indexPage = 'index.html'; | ||
+ | const appName = '{branchname}'; // TODO: Get from ENV VAR | ||
+ | |||
+ | exports.handler = async (event, context, callback) => { | ||
+ | |||
+ | console.log(JSON.stringify(event, null, 2)); | ||
+ | |||
+ | const cf = event.Records[0].cf; | ||
+ | const request = cf.request; | ||
+ | const response = cf.response; | ||
+ | const statusCode = response.status; | ||
+ | |||
+ | // Only replace 403 and 404 requests typically received | ||
+ | // when loading a page for a SPA that uses client-side routing | ||
+ | const doReplace = request.method === 'GET' | ||
+ | && (statusCode == '403' || statusCode == '404'); | ||
+ | |||
+ | const result = doReplace | ||
+ | ? await generateResponseAndLog(cf, request, indexPage) | ||
+ | : response; | ||
+ | |||
+ | callback(null, result); | ||
+ | }; | ||
+ | |||
+ | async function generateResponseAndLog(cf, request, indexPage){ | ||
+ | |||
+ | const domain = cf.config.distributionDomainName; | ||
+ | //const appPath = getAppPath(request.uri); | ||
+ | //const indexPath = `/${appPath}/${indexPage}`; | ||
+ | const indexPath = `/${appName}/${indexPage}`; | ||
+ | console.log(`generateResponseAndLog - domain: ${domain}`); | ||
+ | console.log(`generateResponseAndLog - indexPath: ${indexPath}`); | ||
+ | |||
+ | const response = await generateResponse(domain, indexPath); | ||
+ | |||
+ | console.log('response: ' + JSON.stringify(response)); | ||
+ | |||
+ | return response; | ||
+ | } | ||
+ | |||
+ | async function generateResponse(domain, path){ | ||
+ | try { | ||
+ | |||
+ | // Load HTML index from the CloudFront cache | ||
+ | const s3Response = await httpGet({ hostname: domain, path: path }); | ||
+ | |||
+ | const headers = s3Response.headers || | ||
+ | { | ||
+ | 'content-type': [{ value: 'text/html;charset=UTF-8' }] | ||
+ | }; | ||
+ | |||
+ | return { | ||
+ | status: '200', | ||
+ | headers: wrapAndFilterHeaders(headers), | ||
+ | body: s3Response.body | ||
+ | }; | ||
+ | } catch (error) { | ||
+ | return { | ||
+ | status: '500', | ||
+ | headers:{ | ||
+ | 'content-type': [{ value: 'text/plain' }] | ||
+ | }, | ||
+ | body: 'An error occurred loading the page' | ||
+ | }; | ||
+ | } | ||
+ | } | ||
+ | |||
+ | function httpGet(params) { | ||
+ | return new Promise((resolve, reject) => { | ||
+ | http.get(params, (resp) => { | ||
+ | console.log(`httpGet - Fetching ${params.hostname}${params.path}, status code : ${resp.statusCode}`); | ||
+ | let result = { | ||
+ | headers: resp.headers, | ||
+ | body: '' | ||
+ | }; | ||
+ | resp.on('data', (chunk) => { result.body += chunk; }); | ||
+ | resp.on('end', () => { resolve(result); }); | ||
+ | }).on('error', (err) => { | ||
+ | console.log(`httpGet - Couldn't fetch ${params.hostname}${params.path} : ${err.message}`); | ||
+ | reject(err, null); | ||
+ | }); | ||
+ | }); | ||
+ | } | ||
+ | |||
+ | // | ||
+ | |||
+ | // Cloudfront requires header values to be wrapped in an array | ||
+ | function wrapAndFilterHeaders(headers){ | ||
+ | const allowedHeaders = [ | ||
+ | 'content-type', | ||
+ | 'content-length', | ||
+ | 'last-modified', | ||
+ | 'date', | ||
+ | 'etag', | ||
+ | 'transfer-encoding' | ||
+ | ]; | ||
+ | |||
+ | const responseHeaders = {}; | ||
+ | |||
+ | if(!headers){ | ||
+ | return responseHeaders; | ||
+ | } | ||
+ | |||
+ | for(var propName in headers) { | ||
+ | // only include allowed headers | ||
+ | if(allowedHeaders.includes(propName.toLowerCase())){ | ||
+ | var header = headers[propName]; | ||
+ | |||
+ | if (Array.isArray(header)){ | ||
+ | // assume already 'wrapped' format | ||
+ | responseHeaders[propName] = header; | ||
+ | } else { | ||
+ | // fix to required format | ||
+ | responseHeaders[propName] = [{ value: header }]; | ||
+ | } | ||
+ | } | ||
+ | |||
+ | } | ||
+ | |||
+ | console.log(`wrapAndFilterHeaders - headers: ${JSON.stringify(responseHeaders)}`); | ||
+ | |||
+ | return responseHeaders; | ||
+ | }</nowiki> | ||
+ | </div> | ||
+ | </div> |
Latest revision as of 10:59, 19 December 2019
Quick Reference | AWS CLI | CloudFormation
Contents |
[edit] AWS Cloudformation CLI
[edit] Testing template
https://docs.aws.amazon.com/cli/latest/reference/cloudformation/validate-template.html
~$ aws cloudformation validate-template --template-body file://$HOME/s3_ec2.yaml { "CapabilitiesReason": "The following resource(s) require capabilities: [AWS::IAM::Policy]", "Capabilities": [ "CAPABILITY_IAM" ], "Parameters": [ { "DefaultValue": "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2", "NoEcho": false, "ParameterKey": "LatestAmiId" }, { "NoEcho": false, "Description": "Random String", "ParameterKey": "Entropy" } ] }
[edit] Creating stack
https://docs.aws.amazon.com/cli/latest/reference/cloudformation/create-stack.html
~$ aws cloudformation create-stack --stack-name s3ec2stack01 --template-body file://$HOME/s3_ec2.yaml --parameters ParameterKey=Entropy,ParameterValue=testentropy ParameterKey=BranchName,ParameterValue=testbranch --capabilities CAPABILITY_IAM { "StackId": "arn:aws:cloudformation:us-east-1:548975612458:stack/s3ec2stack01/177a94e0-9923-23et-b86a-0a5462e42a87" }
[edit] Template Examples
[edit] S3 Bucket
Here is a CloudFormation Template (in yaml) that I used to create a S3 bucket with the following requirements:
- Public Read
- 7 day retention policy
- Bucket Policy allowing access to all resources.
- Export the S3 bucket name, Secure URL, and Website URL for usage in other templates.
AWS Template:
AWSTemplateFormatVersion: 2010-09-09 Parameters: BranchName: Description: Branch Name Type: String Resources: S3Bucket: Type: AWS::S3::Bucket Properties: AccessControl: PublicRead LifecycleConfiguration: Rules: - Status: Enabled ExpirationInDays: 7 WebsiteConfiguration: IndexDocument: index.html S3BucketPolicy: Type: AWS::S3::BucketPolicy Properties: Bucket: Ref: S3Bucket PolicyDocument: Statement: - Sid: AddPerm Effect: 'Allow' Principal: '*' Action: - 's3:GetObject' Resource: Fn::Join: - '' - - 'arn:aws:s3:::' - Ref: 'S3Bucket' - '/**' Outputs: S3BucketName: Value: Ref: S3Bucket Description: The S3 bucket name Export: Name: Fn::Sub: ${AWS::StackName}-S3BucketName S3BucketSecureURL: Value: Fn::Join: ['', ['http://', !GetAtt [S3Bucket, DomainName]]] Description: Name of S3 bucket to hold Tenant Management website content S3WebsiteURL: Description: Website URL of the S3 Bucket Value: !Select [1, !Split ["//", !GetAtt S3Bucket.WebsiteURL]] Export: Name: !Sub "${AWS::StackName}-S3WebsiteURL"
[edit] Lambda & CloudFront
Here is a CloudFormation Template (in yaml) that I used to create a 2 Lambda functions and a cloudfront distribution with the following requirements:
- use outputs from S3 bucket stack
- create lambda functions that will work with lambda edge.
- setup the needed IAM roles for lambda functions to execute on lambda edge.
- create cloudfront distribution that uses the lambda edge functions and configures the needed behaviors to use them.
AWS Template:
AWSTemplateFormatVersion: 2010-09-09 Parameters: BranchName: Description: Branch Name Type: String S3BucketStack: Description: S3 Bucket Stack Name Type: String Default: SYNC-s3-dev-ui-stack Entropy: Description: Random String Type: String Resources: LambdaEdgeRoleRequest: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com - edgelambda.amazonaws.com Action: sts:AssumeRole Path: "/service-role/" RoleName: Fn::Sub: 'LambaEdgeRoleRequest-${BranchName}-${Entropy}' Policies: - PolicyName: Fn::Sub: 'AWSLambdaBasicExecutionRole-OriginRequest-${Entropy}' PolicyDocument: Version: 2012-10-17 Statement: - Effect: "Allow" Action: - "logs:CreateLogGroup" Resource: Fn::Sub: "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*" - Effect: "Allow" Action: - "logs:CreateLogStream" - "logs:PutLogEvents" Resource: Fn::Sub: "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/SyncUI-${BranchName}-OriginRequest-${Entropy}:*" LambdaFunctionRequest: Type: AWS::Lambda::Function DependsOn: LambdaEdgeRoleRequest DeletionPolicy: Retain Properties: Handler: "index.handler" Role: Fn::GetAtt: - "LambdaEdgeRoleRequest" - "Arn" Runtime: "nodejs10.x" Code: S3Bucket: Fn::ImportValue: !Sub ${S3BucketStack}-S3BucketName S3Key: !Sub "${BranchName}/OriginRequest.zip" FunctionName: Fn::Sub: 'SyncUI-${BranchName}-OriginRequest-${Entropy}' LambdaFunctionRequestVersion: Type: AWS::Lambda::Version Properties: FunctionName: Ref: "LambdaFunctionRequest" LambdaEdgeRoleResponse: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com - edgelambda.amazonaws.com Action: sts:AssumeRole Path: "/service-role/" RoleName: Fn::Sub: 'LambaEdgeRoleResponse-${BranchName}-${Entropy}' Policies: - PolicyName: Fn::Sub: 'AWSLambdaBasicExecutionRole-OriginResponse-${Entropy}' PolicyDocument: Version: 2012-10-17 Statement: - Effect: "Allow" Action: - "logs:CreateLogGroup" Resource: Fn::Sub: "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*" - Effect: "Allow" Action: - "logs:CreateLogStream" - "logs:PutLogEvents" Resource: Fn::Sub: "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/SyncUI-${BranchName}-OriginResponse-${Entropy}:*" LambdaFunctionResponse: Type: AWS::Lambda::Function DependsOn: LambdaEdgeRoleResponse DeletionPolicy: Retain Properties: Handler: "index.handler" Role: Fn::GetAtt: - "LambdaEdgeRoleResponse" - "Arn" Runtime: "nodejs10.x" Code: S3Bucket: Fn::ImportValue: !Sub ${S3BucketStack}-S3BucketName S3Key: !Sub "${BranchName}/OriginResponse.zip" FunctionName: Fn::Sub: 'SyncUI-${BranchName}-OriginResponse-${Entropy}' LambdaFunctionResponseVersion: Type: AWS::Lambda::Version Properties: FunctionName: Ref: "LambdaFunctionResponse" CloudFrontDistribution: Type: AWS::CloudFront::Distribution DependsOn: - LambdaFunctionRequest - LambdaFunctionResponse Properties: DistributionConfig: Enabled: 'true' Origins: - DomainName: Fn::ImportValue: !Sub "${S3BucketStack}-S3WebsiteURL" OriginPath: Fn::Sub: '/${BranchName}' CustomOriginConfig: OriginProtocolPolicy: match-viewer Id: !Sub "CFID-${BranchName}-${Entropy}" DefaultCacheBehavior: TargetOriginId: !Sub "CFID-${BranchName}-${Entropy}" ForwardedValues: QueryString: 'false' ViewerProtocolPolicy: allow-all LambdaFunctionAssociations: - EventType: origin-request LambdaFunctionARN: !Ref LambdaFunctionRequestVersion - EventType: origin-response LambdaFunctionARN: !Ref LambdaFunctionResponseVersion CacheBehaviors: - PathPattern: Fn::Sub: '/${BranchName}' ForwardedValues: QueryString: false TargetOriginId: !Sub "CFID-${BranchName}-${Entropy}" ViewerProtocolPolicy: allow-all LambdaFunctionAssociations: - EventType: origin-request LambdaFunctionARN: !Ref LambdaFunctionRequestVersion - EventType: origin-response LambdaFunctionARN: !Ref LambdaFunctionResponseVersion - PathPattern: Fn::Sub: '/${BranchName}/*' ForwardedValues: QueryString: false TargetOriginId: !Sub "CFID-${BranchName}-${Entropy}" ViewerProtocolPolicy: allow-all LambdaFunctionAssociations: - EventType: origin-request LambdaFunctionARN: !Ref LambdaFunctionRequestVersion - EventType: origin-response LambdaFunctionARN: !Ref LambdaFunctionResponseVersion
OriginRequest.js
'use strict'; const pointsToFile = uri => /\/[^/]+\.[^/]+$/.test(uri); const appName = '{branchname}'; exports.handler = (event, context, callback) => { console.log(JSON.stringify(event, null, 2)); // Extract the request from the CloudFront event that is sent to Lambda@Edge const request = event.Records[0].cf.request; // Extract the URI from the request const oldUri = request.uri; let newUri; if (!pointsToFile(oldUri) && !oldUri.endsWith('/')) { newUri = request.querystring ? `${oldUri}/?${request.querystring}` : `${oldUri}/`; console.log('new uri for path: ' + newUri); return callback(null, { body: '', status: '301', statusDescription: 'Moved Permanently', headers: { location: [{ key: 'Location', value: newUri }], } }); } else { newUri = oldUri; } // Match any '/' that occurs at the end of a URI. Replace it with a default index newUri = newUri.replace(`/${appName}`, ''); // Log the URI as received by CloudFront and the new URI to be used to fetch from origin console.log("Original URI: " + oldUri); console.log("Updated URI: " + newUri); // Replace the received URI with the URI that includes the index page request.uri = newUri; // Return to CloudFront return callback(null, request); };
OriginResponse.js
'use strict'; const http = require('https'); const indexPage = 'index.html'; const appName = '{branchname}'; // TODO: Get from ENV VAR exports.handler = async (event, context, callback) => { console.log(JSON.stringify(event, null, 2)); const cf = event.Records[0].cf; const request = cf.request; const response = cf.response; const statusCode = response.status; // Only replace 403 and 404 requests typically received // when loading a page for a SPA that uses client-side routing const doReplace = request.method === 'GET' && (statusCode == '403' || statusCode == '404'); const result = doReplace ? await generateResponseAndLog(cf, request, indexPage) : response; callback(null, result); }; async function generateResponseAndLog(cf, request, indexPage){ const domain = cf.config.distributionDomainName; //const appPath = getAppPath(request.uri); //const indexPath = `/${appPath}/${indexPage}`; const indexPath = `/${appName}/${indexPage}`; console.log(`generateResponseAndLog - domain: ${domain}`); console.log(`generateResponseAndLog - indexPath: ${indexPath}`); const response = await generateResponse(domain, indexPath); console.log('response: ' + JSON.stringify(response)); return response; } async function generateResponse(domain, path){ try { // Load HTML index from the CloudFront cache const s3Response = await httpGet({ hostname: domain, path: path }); const headers = s3Response.headers || { 'content-type': [{ value: 'text/html;charset=UTF-8' }] }; return { status: '200', headers: wrapAndFilterHeaders(headers), body: s3Response.body }; } catch (error) { return { status: '500', headers:{ 'content-type': [{ value: 'text/plain' }] }, body: 'An error occurred loading the page' }; } } function httpGet(params) { return new Promise((resolve, reject) => { http.get(params, (resp) => { console.log(`httpGet - Fetching ${params.hostname}${params.path}, status code : ${resp.statusCode}`); let result = { headers: resp.headers, body: '' }; resp.on('data', (chunk) => { result.body += chunk; }); resp.on('end', () => { resolve(result); }); }).on('error', (err) => { console.log(`httpGet - Couldn't fetch ${params.hostname}${params.path} : ${err.message}`); reject(err, null); }); }); } // // Cloudfront requires header values to be wrapped in an array function wrapAndFilterHeaders(headers){ const allowedHeaders = [ 'content-type', 'content-length', 'last-modified', 'date', 'etag', 'transfer-encoding' ]; const responseHeaders = {}; if(!headers){ return responseHeaders; } for(var propName in headers) { // only include allowed headers if(allowedHeaders.includes(propName.toLowerCase())){ var header = headers[propName]; if (Array.isArray(header)){ // assume already 'wrapped' format responseHeaders[propName] = header; } else { // fix to required format responseHeaders[propName] = [{ value: header }]; } } } console.log(`wrapAndFilterHeaders - headers: ${JSON.stringify(responseHeaders)}`); return responseHeaders; }