Update CloudFormation template. (#435)

* Update CloudFormation template.

* Inline Lambda code into CloudFormation template.
read x-distribution-name header in TileJSON response if PUBLIC_HOSTNAME is not set.

* include both build-zip and build-cloudformation-stack in CI
This commit is contained in:
Brandon Liu
2024-09-02 15:10:12 +08:00
committed by GitHub
parent 2dd5bb7937
commit bcd5571ba5
6 changed files with 167 additions and 96 deletions

View File

@@ -21,7 +21,7 @@ jobs:
- run: cd js && npm install && npm run build - run: cd js && npm install && npm run build
- run: echo "VITE_GIT_SHA=$(git rev-parse --short HEAD)" >> app/.env - run: echo "VITE_GIT_SHA=$(git rev-parse --short HEAD)" >> app/.env
- run: cd app && npm install && ./node_modules/.bin/tsc && npm run prettier-check && ./node_modules/.bin/vite build - run: cd app && npm install && ./node_modules/.bin/tsc && npm run prettier-check && ./node_modules/.bin/vite build
- run: cd serverless/aws && npm install && npx tsc && npm run biome-check && npm run build && cp dist/lambda_function.zip ../../app/dist - run: cd serverless/aws && npm install && npx tsc && npm run biome-check && npm run build-zip && cp dist/lambda_function.zip ../../app/dist && npm run build-cloudformation-stack && cp dist/cloudformation-stack.yaml ../../app/dist
- run: cd serverless/cloudflare && cp wrangler.toml.example wrangler.toml && npm install && npx tsc && npm run biome-check && npm run build && cp dist/index.js ../../app/dist - run: cd serverless/cloudflare && cp wrangler.toml.example wrangler.toml && npm install && npx tsc && npm run biome-check && npm run build && cp dist/index.js ../../app/dist
- run: cd spec/v3 && cp *.pmtiles ../../app/dist - run: cd spec/v3 && cp *.pmtiles ../../app/dist
- run: cd js/examples && mkdir ../../app/dist/examples && cp *.html ../../app/dist/examples/ - run: cd js/examples && mkdir ../../app/dist/examples && cp *.html ../../app/dist/examples/

View File

@@ -0,0 +1,7 @@
import { yamlParse, yamlDump } from 'yaml-cfn';
import fs from 'fs';
const template = yamlParse(fs.readFileSync('protomaps-template.yaml','utf-8'));
const code = fs.readFileSync('dist/index.js','utf8');
template.Resources.LambdaFunction.Properties.Code = {ZipFile:code};
fs.writeFileSync('dist/cloudformation-stack.yaml', yamlDump(template));

View File

@@ -17,7 +17,8 @@
"@types/node": "^18.11.2", "@types/node": "^18.11.2",
"esbuild": "^0.20.0", "esbuild": "^0.20.0",
"tsx": "^4.7.0", "tsx": "^4.7.0",
"typescript": "^4.8.4" "typescript": "^4.8.4",
"yaml-cfn": "^0.3.2"
} }
}, },
"node_modules/@aws-crypto/crc32": { "node_modules/@aws-crypto/crc32": {
@@ -1991,6 +1992,13 @@
"integrity": "sha512-BWN3M23gLO2jVG8g/XHIRFWiiV4/GckeFIqbU/C4V3xpoBBWSMk4OZomouN0wCkfQFPqgZikyLr7DOYDysIkkw==", "integrity": "sha512-BWN3M23gLO2jVG8g/XHIRFWiiV4/GckeFIqbU/C4V3xpoBBWSMk4OZomouN0wCkfQFPqgZikyLr7DOYDysIkkw==",
"dev": true "dev": true
}, },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/bowser": { "node_modules/bowser": {
"version": "2.11.0", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz",
@@ -2081,6 +2089,19 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
} }
}, },
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/resolve-pkg-maps": { "node_modules/resolve-pkg-maps": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -2545,6 +2566,19 @@
"bin": { "bin": {
"uuid": "dist/bin/uuid" "uuid": "dist/bin/uuid"
} }
},
"node_modules/yaml-cfn": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/yaml-cfn/-/yaml-cfn-0.3.2.tgz",
"integrity": "sha512-MvrWhv40GKWHFGCliTGGAMwAeqIXf/bzf6WW48+xND9iMp8cTj0R8xkwM0lX/GzNN/EZKr5gP4Hx63Fn+sICoA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"js-yaml": "^4.0.0"
},
"bin": {
"yaml-cfn": "cli.js"
}
} }
}, },
"dependencies": { "dependencies": {
@@ -3995,6 +4029,12 @@
"integrity": "sha512-BWN3M23gLO2jVG8g/XHIRFWiiV4/GckeFIqbU/C4V3xpoBBWSMk4OZomouN0wCkfQFPqgZikyLr7DOYDysIkkw==", "integrity": "sha512-BWN3M23gLO2jVG8g/XHIRFWiiV4/GckeFIqbU/C4V3xpoBBWSMk4OZomouN0wCkfQFPqgZikyLr7DOYDysIkkw==",
"dev": true "dev": true
}, },
"argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
},
"bowser": { "bowser": {
"version": "2.11.0", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz",
@@ -4055,6 +4095,15 @@
"resolve-pkg-maps": "^1.0.0" "resolve-pkg-maps": "^1.0.0"
} }
}, },
"js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"requires": {
"argparse": "^2.0.1"
}
},
"resolve-pkg-maps": { "resolve-pkg-maps": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -4286,6 +4335,15 @@
"version": "8.3.2", "version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
},
"yaml-cfn": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/yaml-cfn/-/yaml-cfn-0.3.2.tgz",
"integrity": "sha512-MvrWhv40GKWHFGCliTGGAMwAeqIXf/bzf6WW48+xND9iMp8cTj0R8xkwM0lX/GzNN/EZKr5gP4Hx63Fn+sICoA==",
"dev": true,
"requires": {
"js-yaml": "^4.0.0"
}
} }
} }
} }

View File

@@ -7,12 +7,14 @@
"@types/node": "^18.11.2", "@types/node": "^18.11.2",
"esbuild": "^0.20.0", "esbuild": "^0.20.0",
"tsx": "^4.7.0", "tsx": "^4.7.0",
"typescript": "^4.8.4" "typescript": "^4.8.4",
"yaml-cfn": "^0.3.2"
}, },
"private": true, "private": true,
"scripts": { "scripts": {
"tsc": "tsc --noEmit --watch", "tsc": "tsc --noEmit --watch",
"build": "esbuild src/index.ts --target=es2020 --outfile=dist/index.mjs --format=esm --bundle --platform=node --target=node18 --external:@aws-sdk/client-s3 --external:@aws-sdk/node-http-handler --banner:js=//$(git describe --always) && cd dist && zip lambda_function.zip index.mjs", "build-zip": "esbuild src/index.ts --target=es2020 --outfile=dist/index.mjs --format=esm --bundle --platform=node --target=node20 --external:@aws-sdk/client-s3 --external:@aws-sdk/node-http-handler --banner:js=//$(git describe --always) && cd dist && zip lambda_function.zip index.mjs",
"build-cloudformation-stack": "esbuild src/index.ts --target=es2020 --minify --outfile=dist/index.js --format=cjs --bundle --platform=node --target=node20 --external:@aws-sdk/client-s3 --external:@aws-sdk/node-http-handler --banner:js=//sha:$(git describe --always) && tsx inline_lambda.ts",
"test": "tsx ../shared/index.test.ts", "test": "tsx ../shared/index.test.ts",
"biome": "biome check --config-path=../../js/ src --apply", "biome": "biome check --config-path=../../js/ src --apply",
"biome-check": "biome check --config-path=../../js src" "biome-check": "biome check --config-path=../../js src"

View File

@@ -1,36 +1,39 @@
AWSTemplateFormatVersion: '2010-09-09' AWSTemplateFormatVersion: '2010-09-09'
Description: CloudFormation template to create a protomaps infraestructure to serve tiles. Description: Serve Z/X/Y tiles through CloudFront + Lambda from an existing S3 bucket.
Parameters: Parameters:
BucketName: BucketName:
Description: 'The name of the S3 bucket where you will store pmtiles files to be served (must be globally unique)' Description: 'Name of an existing S3 bucket with .pmtiles tilesets. Should be in the same region as your CloudFormation stack.'
Type: String Type: String
MinLength: 1
CodeBucketName: AllowedOrigins:
Description: 'The S3 bucket name where the Lambda function code is stored (e.g., lambda-protomaps-code)' Description: 'Comma-separated list of domains (e.g. example.com) allowed by browser CORS policy, or * for all origins.'
Type: String Type: List<String>
Default: "*"
CodeKey:
Description: 'The S3 key for the Lambda function code (e.g., lambda_function.zip)'
Type: String
PublicHostname: PublicHostname:
Description: 'The public custom domain name for your CloudFront distribution' Description: 'Optional. Replace *.cloudfront.net in TileJSON with a custom hostname (e.g. example.com). See docs on how this value is cached.'
Type: String Type: String
Default: 'None'
# ########################################################################## Outputs:
# # S3 Bucket # CloudFrontDistributionUrl:
# ########################################################################## Description: 'URL of the CloudFront distribution'
Value: !Sub "https://${CloudFrontDistribution.DomainName}"
Export:
Name: !Sub "${AWS::StackName}-CloudFrontDistributionURL"
Conditions:
IsPublicHostnameProvided:
Fn::Not:
- Fn::Equals:
- Ref: PublicHostname
- ''
Resources: Resources:
S3Bucket: LogGroup:
Type: 'AWS::S3::Bucket' Type: AWS::Logs::LogGroup
Properties: Properties:
BucketName: !Ref BucketName LogGroupName: !Sub "/aws/lambda/${AWS::StackName}"
PublicAccessBlockConfiguration: RetentionInDays: 7
BlockPublicAcls: true
IgnorePublicAcls: true
BlockPublicPolicy: true
RestrictPublicBuckets: true
LambdaExecutionRole: LambdaExecutionRole:
Type: 'AWS::IAM::Role' Type: 'AWS::IAM::Role'
@@ -43,17 +46,16 @@ Resources:
Service: lambda.amazonaws.com Service: lambda.amazonaws.com
Action: sts:AssumeRole Action: sts:AssumeRole
Policies: Policies:
- PolicyName: LambdaBasicExecution - PolicyName: !Sub "${AWS::StackName}-LambdaLoggingPolicy"
PolicyDocument: PolicyDocument:
Version: '2012-10-17' Version: '2012-10-17'
Statement: Statement:
- Effect: Allow - Effect: Allow
Action: Action:
- logs:CreateLogGroup
- logs:CreateLogStream - logs:CreateLogStream
- logs:PutLogEvents - logs:PutLogEvents
Resource: '*' Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${AWS::StackName}:log-stream:*"
- PolicyName: S3AccessPolicy - PolicyName: !Sub "${AWS::StackName}-LambdaS3AccessPolicy"
PolicyDocument: PolicyDocument:
Version: '2012-10-17' Version: '2012-10-17'
Statement: Statement:
@@ -61,76 +63,70 @@ Resources:
Action: s3:GetObject Action: s3:GetObject
Resource: !Sub arn:aws:s3:::${BucketName}/* Resource: !Sub arn:aws:s3:::${BucketName}/*
##########################################################################
# Lambda Function #
##########################################################################
ProtomapsLambdaFunction: LambdaFunctionUrl:
Type: 'AWS::Lambda::Function'
Properties:
FunctionName: protomaps
Runtime: nodejs18.x
Architectures: [arm64]
Role: !GetAtt LambdaExecutionRole.Arn
Handler: index.handler
MemorySize: 512
Environment:
Variables:
BUCKET: !Ref BucketName
PUBLIC_HOSTNAME: !Ref PublicHostname
Code:
S3Bucket: !Ref CodeBucketName
S3Key: !Ref CodeKey
ProtomapsLambdaFunctionUrl:
Type: 'AWS::Lambda::Url' Type: 'AWS::Lambda::Url'
Properties: Properties:
AuthType: NONE AuthType: NONE
TargetFunctionArn: !GetAtt ProtomapsLambdaFunction.Arn TargetFunctionArn: !GetAtt LambdaFunction.Arn
Cors:
AllowOrigins: ["*"]
InvokeMode: BUFFERED InvokeMode: BUFFERED
ProtomapsLambdaFunctionUrlPermission: LambdaFunctionUrlPermission:
Type: 'AWS::Lambda::Permission' Type: 'AWS::Lambda::Permission'
Properties: Properties:
Action: lambda:InvokeFunctionUrl Action: lambda:InvokeFunctionUrl
FunctionName: !Ref ProtomapsLambdaFunction FunctionName: !Ref LambdaFunction
Principal: '*' Principal: '*'
FunctionUrlAuthType: NONE FunctionUrlAuthType: NONE
# ########################################################################## ViewerRequestCloudFrontFunction:
# # CloudFront::Distribution # Type: AWS::CloudFront::Function
# ########################################################################## Properties:
Name: !Sub "${AWS::StackName}-ViewerRequestCloudFrontFunction"
AutoPublish: true
FunctionCode: |
function handler(event) {
const request = event.request;
request.headers['x-distribution-domain-name'] = { value: event.context.distributionDomainName };
return request;
}
FunctionConfig:
Comment: 'Add x-distribution-domain header.'
Runtime: cloudfront-js-2.0
CloudFrontDistribution: CloudFrontDistribution:
Type: 'AWS::CloudFront::Distribution' Type: 'AWS::CloudFront::Distribution'
DeletionPolicy: Delete
Properties: Properties:
DistributionConfig: DistributionConfig:
Origins: Origins:
- Id: ProtomapsLambdaOrigin - Id: LambdaOrigin
DomainName: !Select [2, !Split ["/", !GetAtt ProtomapsLambdaFunctionUrl.FunctionUrl]] DomainName: !Select [2, !Split ["/", !GetAtt LambdaFunctionUrl.FunctionUrl]]
CustomOriginConfig: CustomOriginConfig:
OriginProtocolPolicy: https-only OriginProtocolPolicy: https-only
DefaultCacheBehavior: DefaultCacheBehavior:
TargetOriginId: ProtomapsLambdaOrigin TargetOriginId: LambdaOrigin
ViewerProtocolPolicy: redirect-to-https ViewerProtocolPolicy: redirect-to-https
CachePolicyId: !Ref CachePolicyId CachePolicyId: !Ref CachePolicyId
ResponseHeadersPolicyId: !Ref ResponseHeadersPolicyId ResponseHeadersPolicyId: !Ref ResponseHeadersPolicyId
OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac
FunctionAssociations:
Fn::If:
- IsPublicHostnameProvided
- !Ref "AWS::NoValue"
-
- EventType: viewer-request
FunctionARN: !GetAtt ViewerRequestCloudFrontFunction.FunctionARN
Enabled: true Enabled: true
HttpVersion: http2and3 HttpVersion: http2and3
Comment: "Protomaps CloudFront Distribution" Comment: "CloudFront Distribution"
PriceClass: PriceClass_All # Change this to save cost and distribute to fewer countries. Check https://aws.amazon.com/cloudfront/pricing/ PriceClass: PriceClass_All # Change this to save cost and distribute to fewer countries. Check https://aws.amazon.com/cloudfront/pricing/
# ##########################################################################
# # CloudFront::CachePolicy #
# ##########################################################################
CachePolicyId: CachePolicyId:
Type: 'AWS::CloudFront::CachePolicy' Type: 'AWS::CloudFront::CachePolicy'
Properties: Properties:
CachePolicyConfig: CachePolicyConfig:
Name: 'CachingOptimized' Name: !Sub "${AWS::StackName}-CachePolicyConfig"
DefaultTTL: 86400 DefaultTTL: 86400
MaxTTL: 31536000 MaxTTL: 31536000
MinTTL: 0 MinTTL: 0
@@ -146,42 +142,43 @@ Resources:
ResponseHeadersPolicyId: ResponseHeadersPolicyId:
Type: 'AWS::CloudFront::ResponseHeadersPolicy' Type: 'AWS::CloudFront::ResponseHeadersPolicy'
# DeletionPolicy: Delete
Properties: Properties:
ResponseHeadersPolicyConfig: ResponseHeadersPolicyConfig:
Name: 'protomaps-cors' Name: !Sub "${AWS::StackName}-ResponseHeadersPolicyConfig"
CorsConfig: CorsConfig:
AccessControlAllowOrigins: AccessControlAllowOrigins:
Items: Items: !Ref AllowedOrigins
- 'https://example.com' # Replace with your allowed origin
AccessControlAllowHeaders: AccessControlAllowHeaders:
Items: Items:
- '*' - '*'
AccessControlAllowMethods: AccessControlAllowMethods:
Items: Items:
- GET - GET
- POST - HEAD
- OPTIONS - OPTIONS
AccessControlExposeHeaders:
Items:
- ETag
AccessControlAllowCredentials: false # Set to true if you want to include credentials AccessControlAllowCredentials: false # Set to true if you want to include credentials
OriginOverride: true OriginOverride: true
Comment: 'CORS policy for Protomaps' Comment: 'CORS policy for Protomaps'
DeletionPolicy: Delete
Outputs:
BucketNameOutput:
Description: 'URL of the S3 bucket'
Value: !Sub "https://s3.console.aws.amazon.com/s3/buckets/${BucketName}"
Export:
Name: !Sub "${AWS::StackName}-S3BucketURL"
LambdaFunctionUrl:
Description: 'URL of the Lambda function'
Value: !GetAtt ProtomapsLambdaFunctionUrl.FunctionUrl
Export:
Name: !Sub "${AWS::StackName}-LambdaFunctionURL"
CloudFrontDistributionUrl:
Description: 'URL of the CloudFront distribution'
Value: !Sub "https://${CloudFrontDistribution.DomainName}"
Export:
Name: !Sub "${AWS::StackName}-CloudFrontDistributionURL"
LambdaFunction:
Type: 'AWS::Lambda::Function'
Properties:
FunctionName: !Sub "${AWS::StackName}-LambdaFunction"
Runtime: nodejs20.x
Architectures: [arm64]
Role: !GetAtt LambdaExecutionRole.Arn
Handler: index.handler
MemorySize: 512
LoggingConfig:
LogGroup: !Ref LogGroup
Environment:
Variables:
BUCKET: !Ref BucketName
PUBLIC_HOSTNAME: !Ref PublicHostname
Code:
S3Bucket: !Ref BucketName
S3Key: lambda_function.zip

View File

@@ -160,7 +160,12 @@ export const handlerRaw = async (
const header = await p.getHeader(); const header = await p.getHeader();
if (!tile) { if (!tile) {
if (!process.env.PUBLIC_HOSTNAME) { if (
!(
process.env.PUBLIC_HOSTNAME ||
event.headers["x-distribution-domain-name"]
)
) {
return apiResp( return apiResp(
501, 501,
"PUBLIC_HOSTNAME must be set for TileJSON", "PUBLIC_HOSTNAME must be set for TileJSON",
@@ -173,7 +178,9 @@ export const handlerRaw = async (
const t = tileJSON( const t = tileJSON(
header, header,
await p.getMetadata(), await p.getMetadata(),
process.env.PUBLIC_HOSTNAME, process.env.PUBLIC_HOSTNAME ||
event.headers["x-distribution-domain-name"] ||
"",
name name
); );