├── .editorconfig
├── .eslintignore
├── .eslintrc.json
├── .gitignore
├── .npmignore
├── .travis.yml
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── index.js
├── multi-regional-api.png
├── package-lock.json
├── package.json
├── resources.yml
└── test
└── index.test.js
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig is awesome: http://EditorConfig.org
2 |
3 | # top-most EditorConfig file
4 | root = true
5 |
6 | # Unix-style newlines with a newline ending every file
7 | [*]
8 | trim_trailing_whitespace = true
9 | end_of_line = lf
10 | insert_final_newline = true
11 |
12 | # Set default charset
13 | [*.{js,ts,css,json}]
14 | charset = utf-8
15 | indent_style = space
16 | indent_size = 2
17 |
18 | # Matches the exact files package.json
19 | [{package.json}]
20 | indent_style = space
21 | indent_size = 2
22 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | /test/
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "commonjs": true,
5 | "es6": true
6 | },
7 | "extends": "eslint:recommended",
8 | "parserOptions": {
9 | "ecmaVersion": 2018,
10 | "sourceType": "module"
11 | },
12 | "rules": {
13 | "indent": ["error", 2],
14 | "quotes": ["error", "single"],
15 | "semi": ["error", "never"],
16 | "no-var": ["error", "never"]
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | *.log
3 | .DS_Store
4 | .npmrc
5 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .editorconfig
2 | .eslintrc.json
3 | .npmignore
4 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - '8'
4 | - '10'
5 |
6 | install:
7 | - npm install
8 |
9 | script: 'npm run test'
10 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "eslint.validate": ["javascript"],
3 | "eslint.autoFixOnSave": true,
4 | "editor.tabSize": 2,
5 | "editor.formatOnSave": true,
6 | "prettier.singleQuote": true,
7 | "prettier.semi": false,
8 | "prettier.printWidth": 100,
9 | "javascript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": false
10 | }
11 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Q2 Biller Direct Team
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # serverless-multi-region-plugin
2 |
3 | [](https://travis-ci.com/unbill/serverless-multi-region-plugin)
4 |
5 | TLDR;
6 | This plugin adds resources to configure API Gateway regional endpoints for the regions you specify and a global endpoint
7 | in front of a CloudFront installation to front the regional APIs.
8 |
9 | This plugin was forked from serverless-multi-regional-plugin, enhanced and simplified for a true turn-key experience.
10 |
11 | ## More details?
12 |
13 | This plugin will:
14 |
15 | - Set up API Gateways for your lambdas in each region
16 | - Set up a custom domain in each region for the API Gateway and specify the appropriate base path
17 | - Set up a basic HTTPS healthcheck for the API in each region
18 | - Set up Route 53 for failover based routing with failover between regions based on the healthcheck created
19 | - Set up CloudFormation in front of Route 53 failover with TLS 1.2 specified
20 | - Set up Route 53 with the desired domain name in front of CloudFront
21 |
22 |
23 |
24 | ## Install plugin:
25 |
26 | ```
27 | npm install serverless-multi-region-plugin --save-dev
28 | ```
29 |
30 | ## Prerequisites: Create your hosted zone and certificates
31 |
32 | Using the diagram above as an example the hosted zone would be for _example.com_ and the certificate would be for _\*.example.com_. Create the same certificate in each region to support the regional endpoints. The global endpoint requires a certificate in the us-east-1 region.
33 |
34 | ## Configuration
35 |
36 | ### Minimal configuration
37 |
38 | In this configuration, the necessary configuration for certificates and domain names will be derived from the primary domain name.
39 | In addition, default healthchecks will be added for each region. It is assumed that your api has a '/healthcheck' endpoint.
40 | See the Customized Configuration below to change the healthcheck path.
41 |
42 | In your serverless.yml:
43 |
44 | ```
45 | # Set up your plugin
46 | plugins:
47 | - serverless-multi-regional-plugin
48 |
49 | # Add this to the standard SLS "custom" region
50 | custom:
51 | # The API Gateway method CloudFormation LogicalID to await. Defaults to ApiGatewayMethodProxyVarAny.
52 | # Aspects of the templates must await this completion to be created properly.
53 | gatewayMethodDependency: ApiGatewayMethodProxyVarAny
54 |
55 | # Settings used for API Gateway and Route 53
56 | dns:
57 | # In this setup, almost everything is derived from this domain name
58 | domainName: somedomain.example.com
59 |
60 | # Settings used for CloudFront
61 | cdn:
62 | # Indicates which CloudFormation region deployment used to provision CloudFront (because you only need to provision CloudFront once)
63 | region: us-east-1
64 | ```
65 |
66 | ### Customized Configuration
67 |
68 | This is the configuration example from the original "serverless-multi-regional-plugin".
69 | It's important to note that all of these settings can be used with the minimal configuration above
70 | and they will override the convention-based settings.
71 |
72 | ```
73 | # Set up your plugin
74 | plugins:
75 | - serverless-multi-regional-plugin
76 |
77 | # Add this to the standard SLS "custom" region
78 | custom:
79 | # The API Gateway method CloudFormation LogicalID to await. Defaults to ApiGatewayMethodProxyVarAny.
80 | # Aspects of the templates must await this completion to be created properly.
81 | gatewayMethodDependency: ApiGatewayMethodProxyVarAny
82 |
83 | # Settings used for API Gateway and Route 53
84 | dns:
85 | domainName: ${self:service}.example.com
86 | # Explicity specify the regional domain name.
87 | # This must be unique per stage but must be the same in each region for failover to function properly
88 | regionalDomainName: ${self:custom.dns.domainName}-${opt:stage}
89 | # Specify the resource path for the healthcheck (only applicable if you don't specify a healthcheckId below)
90 | # the default is /${opt:stage}/healthcheck
91 | healthCheckResourcePath: /${opt:stage}/healthcheck
92 | # Settings per region for API Gateway and Route 53
93 | us-east-1:
94 | # Specify a certificate by its ARN
95 | acmCertificateArn: arn:aws:acm:us-east-1:870671212434:certificate/55555555-5555-5555-5555-5555555555555555
96 | # Use your own healthcheck by it's ID
97 | healthCheckId: 44444444-4444-4444-4444-444444444444
98 | # Failover type (if not present, defaults to Latency based failover)
99 | failover: PRIMARY
100 | us-west-2:
101 | acmCertificateArn: arn:aws:acm:us-west-2:111111111111:certificate/55555555-5555-5555-5555-5555555555555555
102 | healthCheckId: 33333333-3333-3333-3333-333333333333
103 | failover: SECONDARY
104 |
105 | # Settings used for CloudFront
106 | cdn:
107 | # Indicates which CloudFormation region deployment used to provision CloudFront (because you only need to provision CloudFront once)
108 | region: us-east-1
109 | # Aliases registered in CloudFront
110 | # If aliases is not present, the domain name is set up as an alias by default.
111 | # If *no* aliases are desired, leave an empty aliases section here.
112 | aliases:
113 | - ${self:custom.dns.domainName}
114 | # Add any headers your CloudFront requires here
115 | headers:
116 | - Accept
117 | - Accept-Encoding
118 | - Authorization
119 | - User-Agent
120 | - X-Forwarded-For
121 | # Specify a price class, PriceClass_100 is the default
122 | priceClass: PriceClass_100
123 | # Specify your certificate explicitly by the ARN
124 | # If the certificate is not specified, the best match certificate to the domain name is used by default
125 | acmCertificateArn: ${self:custom.dns.us-east-1.acmCertificateArn}
126 | # Set up logging for CloudFront
127 | logging:
128 | bucket: example-auditing.s3.amazonaws.com
129 | prefix: aws-cloudfront/api/${opt:stage}/${self:service}
130 | # Add the webACLId to your CloudFront
131 | webACLId: id-for-your-webacl
132 | ```
133 |
134 | ## Deploy to each region
135 |
136 | You've got your configuration all set.
137 |
138 | Now perform a serverless depoyment to each region you want your Lambda to operate in.
139 | The items you have specified above are set up appropriately for each region
140 | and non-regional resources such as CloudFront and Route 53 are also set up via CloudFormation in your primary region.
141 |
142 | You now have a Lambda API with cross-region failover!!!
143 |
144 |
145 |
146 | ## Related Documentation
147 |
148 | - [Building a Multi-region Serverless Application with Amazon API Gateway and AWS Lambda](https://aws.amazon.com/blogs/compute/building-a-multi-region-serverless-application-with-amazon-api-gateway-and-aws-lambda)
149 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const _ = require('lodash')
3 | const yaml = require('js-yaml')
4 | const fs = require('fs')
5 |
6 | class Plugin {
7 | constructor(serverless, options) {
8 | this.serverless = serverless
9 | this.options = options
10 | this.hooks = {
11 | 'before:deploy:createDeploymentArtifacts': this.createDeploymentArtifacts.bind(this)
12 | }
13 | }
14 |
15 | createDeploymentArtifacts() {
16 | if (!this.serverless.service.custom.cdn) {
17 | this.serverless.service.custom.cdn = {}
18 | }
19 |
20 | if (!this.serverless.service.custom.dns) {
21 | this.serverless.service.custom.dns = {}
22 | }
23 |
24 | const disabled = this.serverless.service.custom.cdn.disabled
25 | if (disabled != undefined && disabled) {
26 | return
27 | }
28 |
29 | this.fullDomainName = this.serverless.service.custom.dns.domainName
30 | if (!this.fullDomainName) {
31 | this.serverless.cli.log('The domainName parameter is required')
32 | return
33 | }
34 |
35 | const hostSegments = this.fullDomainName.split('.')
36 |
37 | if (hostSegments.length < 3) {
38 | this.serverless.cli.log(`The domainName was not valid: ${this.fullDomainName}.`)
39 | return
40 | }
41 |
42 | this.hostName = `${hostSegments[hostSegments.length - 2]}.${
43 | hostSegments[hostSegments.length - 1]
44 | }`
45 | this.regionalDomainName = this.buildRegionalDomainName(hostSegments)
46 |
47 | const baseResources = this.serverless.service.provider.compiledCloudFormationTemplate
48 |
49 | const filename = path.resolve(__dirname, 'resources.yml') // eslint-disable-line
50 | const content = fs.readFileSync(filename, 'utf-8')
51 | const resources = yaml.safeLoad(content, {
52 | filename: filename
53 | })
54 |
55 | return this.prepareResources(resources).then(() => {
56 | this.serverless.cli.log(
57 | `The multi-regional-plugin completed resources: ${yaml.safeDump(resources)}`
58 | )
59 | _.merge(baseResources, resources)
60 | })
61 | }
62 |
63 | prepareResources(resources) {
64 | const credentials = this.serverless.providers.aws.getCredentials()
65 | const acmCredentials = Object.assign({}, credentials, { region: this.options.region })
66 | this.acm = new this.serverless.providers.aws.sdk.ACM(acmCredentials)
67 |
68 | const distributionConfig = resources.Resources.ApiDistribution.Properties.DistributionConfig
69 | const cloudFrontRegion = this.serverless.service.custom.cdn.region
70 | const enabled = this.serverless.service.custom.cdn.enabled
71 | let createCdn = true
72 | if (
73 | cloudFrontRegion !== this.options.region ||
74 | (enabled && !enabled.includes(this.options.stage))
75 | ) {
76 | createCdn = false
77 | delete resources.Resources.ApiGlobalEndpointRecord
78 | delete resources.Outputs.ApiDistribution
79 | delete resources.Outputs.GlobalEndpoint
80 | } else {
81 | this.prepareCdnComment(distributionConfig)
82 | this.prepareCdnOrigins(distributionConfig)
83 | this.prepareCdnHeaders(distributionConfig)
84 | this.prepareCdnPriceClass(distributionConfig)
85 | this.prepareCdnAliases(distributionConfig)
86 | this.prepareCdnLogging(distributionConfig)
87 | this.prepareCdnWaf(distributionConfig)
88 | this.prepareApiGlobalEndpointRecord(resources)
89 | }
90 |
91 | this.prepareApiRegionalBasePathMapping(resources)
92 | this.prepareApiRegionalEndpointRecord(resources)
93 | this.prepareApiRegionalHealthCheck(resources)
94 |
95 | return this.prepareApiRegionalDomainSettings(resources).then(() => {
96 | if (createCdn) {
97 | return this.prepareCdnCertificate(distributionConfig)
98 | } else {
99 | delete resources.Resources.ApiDistribution
100 | }
101 | })
102 | }
103 |
104 | buildRegionalDomainName(hostSegments) {
105 | let regionalDomainName = this.serverless.service.custom.dns.regionalDomainName
106 | if (!regionalDomainName) {
107 | const lastNonHostSegment = hostSegments[hostSegments.length - 3]
108 | hostSegments[hostSegments.length - 3] = `${lastNonHostSegment}-${this.options.stage}`
109 | regionalDomainName = hostSegments.join('.')
110 | }
111 | return regionalDomainName
112 | }
113 |
114 | prepareApiRegionalDomainSettings(resources) {
115 | const properties = resources.Resources.ApiRegionalDomainName.Properties
116 | properties.DomainName = this.regionalDomainName
117 |
118 | const regionSettings = this.serverless.service.custom.dns[this.options.region]
119 | if (regionSettings) {
120 | const acmCertificateArn = regionSettings.acmCertificateArn
121 | if (acmCertificateArn) {
122 | properties.RegionalCertificateArn = acmCertificateArn
123 | return Promise.resolve()
124 | }
125 | }
126 |
127 | return this.getCertArnFromHostName().then(certArn => {
128 | if (certArn) {
129 | properties.RegionalCertificateArn = certArn
130 | } else {
131 | delete properties.RegionalCertificateArn
132 | }
133 | })
134 | }
135 |
136 | prepareApiRegionalBasePathMapping(resources) {
137 | const apiGatewayStubDeployment = resources.Resources.ApiGatewayStubDeployment
138 | apiGatewayStubDeployment.DependsOn =
139 | this.serverless.service.custom.gatewayMethodDependency || 'ApiGatewayMethodProxyVarAny'
140 | apiGatewayStubDeployment.Properties.StageName = this.options.stage
141 |
142 | const properties = resources.Resources.ApiRegionalBasePathMapping.Properties
143 | properties.Stage = this.options.stage
144 | }
145 |
146 | prepareApiRegionalEndpointRecord(resources) {
147 | const properties = resources.Resources.ApiRegionalEndpointRecord.Properties
148 |
149 | const hostedZoneId = this.serverless.service.custom.dns.hostedZoneId
150 | if (hostedZoneId) {
151 | delete properties.HostedZoneName
152 | properties.HostedZoneId = hostedZoneId
153 | } else {
154 | delete properties.HostedZoneId
155 | properties.HostedZoneName = `${this.hostName}.`
156 | }
157 |
158 | const regionSettings = this.serverless.service.custom.dns[this.options.region]
159 | if (regionSettings && regionSettings.failover) {
160 | delete properties.Region
161 | properties.Failover = regionSettings.failover
162 | } else {
163 | delete properties.Failover
164 | properties.Region = this.options.region
165 | }
166 |
167 | properties.SetIdentifier = this.options.region
168 |
169 | const elements = resources.Outputs.RegionalEndpoint.Value['Fn::Join'][1]
170 | if (elements[2]) {
171 | elements[2] = `/${this.options.stage}`
172 | }
173 | }
174 |
175 | prepareApiRegionalHealthCheck(resources) {
176 | const dnsSettings = this.serverless.service.custom.dns
177 | const regionSettings = dnsSettings[this.options.region]
178 |
179 | const properties = resources.Resources.ApiRegionalEndpointRecord.Properties
180 |
181 | if (regionSettings && regionSettings.healthCheckId) {
182 | properties.HealthCheckId = regionSettings.healthCheckId
183 | delete resources.Resources.ApiRegionalHealthCheck
184 | } else {
185 | const healthCheckProperties = resources.Resources.ApiRegionalHealthCheck.Properties
186 | if (dnsSettings.healthCheckResourcePath) {
187 | healthCheckProperties.HealthCheckConfig.ResourcePath = dnsSettings.healthCheckResourcePath
188 | } else {
189 | healthCheckProperties.HealthCheckConfig.ResourcePath = `/${this.options.stage}/healthcheck`
190 | }
191 | }
192 | }
193 |
194 | prepareCdnComment(distributionConfig) {
195 | const name = this.serverless.getProvider('aws').naming.getApiGatewayName()
196 | distributionConfig.Comment = `API: ${name}`
197 | }
198 |
199 | prepareCdnOrigins(distributionConfig) {
200 | distributionConfig.Origins[0].DomainName = this.regionalDomainName
201 | }
202 |
203 | prepareCdnHeaders(distributionConfig) {
204 | const headers = this.serverless.service.custom.cdn.headers
205 |
206 | if (headers) {
207 | distributionConfig.DefaultCacheBehavior.ForwardedValues.Headers = headers
208 | } else {
209 | distributionConfig.DefaultCacheBehavior.ForwardedValues.Headers = ['Accept', 'Authorization']
210 | }
211 | }
212 |
213 | prepareCdnPriceClass(distributionConfig) {
214 | const priceClass = this.serverless.service.custom.cdn.priceClass
215 |
216 | if (priceClass) {
217 | distributionConfig.PriceClass = priceClass
218 | } else {
219 | distributionConfig.PriceClass = 'PriceClass_100'
220 | }
221 | }
222 |
223 | prepareCdnAliases(distributionConfig) {
224 | let aliases = this.serverless.service.custom.cdn.aliases
225 |
226 | if (aliases) {
227 | if (!aliases.length || aliases.length === 0) {
228 | delete distributionConfig.Aliases
229 | }
230 | distributionConfig.Aliases = aliases
231 | } else {
232 | aliases = [this.fullDomainName]
233 | distributionConfig.Aliases = aliases
234 | }
235 | }
236 |
237 | prepareCdnCertificate(distributionConfig) {
238 | const acmCertificateArn = this.serverless.service.custom.cdn.acmCertificateArn
239 |
240 | if (acmCertificateArn) {
241 | distributionConfig.ViewerCertificate.AcmCertificateArn = acmCertificateArn
242 | return Promise.resolve()
243 | } else {
244 | return this.getCertArnFromHostName().then(certArn => {
245 | if (certArn) {
246 | distributionConfig.ViewerCertificate.AcmCertificateArn = certArn
247 | } else {
248 | delete distributionConfig.ViewerCertificate
249 | }
250 | })
251 | }
252 | }
253 |
254 | prepareCdnLogging(distributionConfig) {
255 | const logging = this.serverless.service.custom.cdn.logging
256 |
257 | if (logging) {
258 | distributionConfig.Logging.Bucket = `${logging.bucketName}.s3.amazonaws.com`
259 | distributionConfig.Logging.Prefix =
260 | logging.prefix ||
261 | `aws-cloudfront/api/${this.options.stage}/${this.serverless
262 | .getProvider('aws')
263 | .naming.getStackName()}`
264 | } else {
265 | delete distributionConfig.Logging
266 | }
267 | }
268 |
269 | prepareCdnWaf(distributionConfig) {
270 | const webACLId = this.serverless.service.custom.cdn.webACLId
271 |
272 | if (webACLId) {
273 | distributionConfig.WebACLId = webACLId
274 | } else {
275 | delete distributionConfig.WebACLId
276 | }
277 | }
278 |
279 | prepareApiGlobalEndpointRecord(resources) {
280 | const properties = resources.Resources.ApiGlobalEndpointRecord.Properties
281 |
282 | const hostedZoneId = this.serverless.service.custom.dns.hostedZoneId
283 | if (hostedZoneId) {
284 | delete properties.HostedZoneName
285 | properties.HostedZoneId = hostedZoneId
286 | } else {
287 | delete properties.HostedZoneId
288 | properties.HostedZoneName = `${this.hostName}.`
289 | }
290 |
291 | properties.Name = `${this.fullDomainName}.`
292 |
293 | const elements = resources.Outputs.GlobalEndpoint.Value['Fn::Join'][1]
294 | if (elements[1]) {
295 | elements[1] = this.fullDomainName
296 | }
297 | }
298 |
299 | /*
300 | * Obtains the certification arn
301 | */
302 | getCertArnFromHostName() {
303 | const certRequest = this.acm
304 | .listCertificates({ CertificateStatuses: ['PENDING_VALIDATION', 'ISSUED', 'INACTIVE'] })
305 | .promise()
306 |
307 | return certRequest
308 | .then(data => {
309 | // The more specific name will be the longest
310 | let nameLength = 0
311 | let certArn
312 | const certificates = data.CertificateSummaryList
313 |
314 | // Derive certificate from domain name
315 | certificates.forEach(certificate => {
316 | let certificateListName = certificate.DomainName
317 |
318 | // Looks for wild card and takes it out when checking
319 | if (certificateListName[0] === '*') {
320 | certificateListName = certificateListName.substr(2)
321 | }
322 |
323 | // Looks to see if the name in the list is within the given domain
324 | // Also checks if the name is more specific than previous ones
325 | if (
326 | this.hostName.includes(certificateListName) &&
327 | certificateListName.length > nameLength
328 | ) {
329 | nameLength = certificateListName.length
330 | certArn = certificate.CertificateArn
331 | }
332 | })
333 | if (certArn) {
334 | this.serverless.cli.log(
335 | `The host name ${this.hostName} resolved to the following certificateArn: ${certArn}`
336 | )
337 | }
338 | return certArn
339 | })
340 | .catch(err => {
341 | throw Error(`Error: Could not list certificates in Certificate Manager.\n${err}`)
342 | })
343 | }
344 | }
345 |
346 | module.exports = Plugin
347 |
--------------------------------------------------------------------------------
/multi-regional-api.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/unbill/serverless-multi-region-plugin/a2a76e2e90b6475c9f6bf6606a53b89ad571dcb9/multi-regional-api.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "serverless-multi-region-plugin",
3 | "version": "1.3.3",
4 | "description": "Deploy an API Gateway service in multiple regions with a global CloudFront distribution and health checks",
5 | "author": "John Gilbert (danteinc.com), Biller Direct Team",
6 | "license": "MIT",
7 | "repository": {
8 | "type": "git",
9 | "url": "git+https://github.com/unbill/serverless-multi-region-plugin.git"
10 | },
11 | "keywords": [
12 | "serverless",
13 | "sls",
14 | "plugin",
15 | "serverless plugin",
16 | "api gateway",
17 | "cloudfront",
18 | "route53",
19 | "lambda",
20 | "aws",
21 | "aws lambda",
22 | "amazon web services",
23 | "serverless.com"
24 | ],
25 | "main": "index.js",
26 | "scripts": {
27 | "lint": "eslint --fix index.js",
28 | "test": "jest"
29 | },
30 | "devDependencies": {
31 | "eslint": "^6.4.0",
32 | "jest": "^24.9.0"
33 | },
34 | "dependencies": {
35 | "js-yaml": "^3.13.1",
36 | "lodash": "^4.17.15"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/resources.yml:
--------------------------------------------------------------------------------
1 | ---
2 | Resources:
3 | ApiGatewayStubDeployment:
4 | Type: AWS::ApiGateway::Deployment
5 | DependsOn: ${self:custom.gatewayMethodDependency}
6 | Properties:
7 | Description: Stub Gateway Deployment
8 | RestApiId:
9 | Ref: ApiGatewayRestApi
10 | StageName: ${opt:stage}
11 | ApiRegionalDomainName:
12 | Type: AWS::ApiGateway::DomainName
13 | Properties:
14 | DomainName: ${self:custom.dns.regionalDomainName}
15 | RegionalCertificateArn: ${self:custom.dns.${opt:region}.acmCertificateArn}
16 | EndpointConfiguration:
17 | Types:
18 | - REGIONAL
19 | ApiRegionalBasePathMapping:
20 | Type: AWS::ApiGateway::BasePathMapping
21 | DependsOn: ApiGatewayStubDeployment
22 | Properties:
23 | DomainName:
24 | Ref: ApiRegionalDomainName
25 | RestApiId:
26 | Ref: ApiGatewayRestApi
27 | Stage: ${opt:stage}
28 | ApiRegionalHealthCheck:
29 | Type: AWS::Route53::HealthCheck
30 | DependsOn: ApiGatewayStubDeployment
31 | Properties:
32 | HealthCheckConfig:
33 | Type: HTTPS
34 | ResourcePath: /${opt:stage}/healthcheck
35 | FullyQualifiedDomainName:
36 | Fn::Join:
37 | - ''
38 | - - Ref: ApiGatewayRestApi
39 | - '.execute-api.'
40 | - Ref: AWS::Region
41 | - '.amazonaws.com'
42 | RequestInterval: 30
43 | FailureThreshold: 3
44 | Regions: [us-east-1, us-west-1, us-west-2]
45 | ApiRegionalEndpointRecord:
46 | Type: AWS::Route53::RecordSet
47 | Properties:
48 | HostedZoneId: ${self:custom.dns.hostedZoneId}
49 | HostedZoneName: ${self:custom.dns.hostedZoneName}
50 | Name:
51 | Fn::Join:
52 | - ''
53 | - - Ref: ApiRegionalDomainName
54 | - .
55 | Region: ${opt:region}
56 | SetIdentifier: ${opt:region}
57 | HealthCheckId:
58 | Ref: ApiRegionalHealthCheck
59 | Type: A
60 | AliasTarget:
61 | HostedZoneId:
62 | Fn::GetAtt:
63 | - ApiRegionalDomainName
64 | - RegionalHostedZoneId
65 | DNSName:
66 | Fn::GetAtt:
67 | - ApiRegionalDomainName
68 | - RegionalDomainName
69 | ApiDistribution:
70 | Type: AWS::CloudFront::Distribution
71 | Properties:
72 | DistributionConfig:
73 | Comment: ${opt:stage}-${self:service} (${opt:region})
74 | Origins:
75 | - Id: ApiGateway
76 | DomainName: ${self:custom.dns.regionalDomainName}
77 | CustomOriginConfig:
78 | HTTPSPort: 443
79 | OriginProtocolPolicy: https-only
80 | OriginSSLProtocols: [TLSv1.2]
81 | Enabled: true
82 | HttpVersion: http2
83 | Aliases: ${self:custom.cdn.aliases}
84 | PriceClass: ${self:custom.cdn.priceClass}
85 | DefaultCacheBehavior:
86 | TargetOriginId: ApiGateway
87 | AllowedMethods:
88 | - DELETE
89 | - GET
90 | - HEAD
91 | - OPTIONS
92 | - PATCH
93 | - POST
94 | - PUT
95 | CachedMethods:
96 | - HEAD
97 | - GET
98 | - OPTIONS
99 | Compress: true
100 | ForwardedValues:
101 | QueryString: true
102 | Headers: ${self:custom.cdn.headers}
103 | # Headers:
104 | # - Accept
105 | # - Authorization
106 | Cookies:
107 | Forward: all
108 | MinTTL: 0
109 | DefaultTTL: 0
110 | ViewerProtocolPolicy: https-only
111 | ViewerCertificate:
112 | AcmCertificateArn: ${self:custom.cdn.acmCertificateArn}
113 | SslSupportMethod: sni-only
114 | MinimumProtocolVersion: 'TLSv1.2_2018'
115 | Logging:
116 | IncludeCookies: true
117 | Bucket: ${self:custom.cdn.logging.bucket}
118 | Prefix: ${self:custom.cdn.logging.prefix}
119 | WebACLId: ${self:custom.cdn.webACLId}
120 | ApiGlobalEndpointRecord:
121 | Type: AWS::Route53::RecordSet
122 | Properties:
123 | HostedZoneId: ${self:custom.dns.hostedZoneId}
124 | HostedZoneName: ${self:custom.dns.hostedZoneName}
125 | Name: ${self:custom.dns.domainName}.
126 | Type: A
127 | AliasTarget:
128 | HostedZoneId: Z2FDTNDATAQYW2
129 | DNSName:
130 | Fn::GetAtt:
131 | - ApiDistribution
132 | - DomainName
133 |
134 | Outputs:
135 | ApiDistribution:
136 | Value:
137 | Fn::GetAtt: [ApiDistribution, DomainName]
138 | RegionalEndpoint:
139 | Value:
140 | Fn::Join:
141 | - ''
142 | - - https://
143 | - Ref: ApiRegionalDomainName
144 | GlobalEndpoint:
145 | Value:
146 | Fn::Join:
147 | - ''
148 | - - https://
149 | - ${self:custom.dns.domainName}
150 |
--------------------------------------------------------------------------------
/test/index.test.js:
--------------------------------------------------------------------------------
1 | const Plugin = require('../index')
2 |
3 | function createServerlessStub() {
4 | return {
5 | service: {
6 | custom: {
7 | dns: {
8 | domainName: 'somedomain.example.com'
9 | }
10 | }
11 | }
12 | }
13 | }
14 |
15 | describe('Plugin', () => {
16 | it('can be created with basic settings', () => {
17 | const serverless = createServerlessStub()
18 | const options = { stage: 'staging' }
19 | const plugin = new Plugin(serverless, options)
20 |
21 | expect(plugin.serverless).toBe(serverless)
22 | expect(plugin.options).toBe(options)
23 | })
24 |
25 | it('will return assigned regional domain name from build', () => {
26 | const serverless = createServerlessStub()
27 | serverless.service.custom.dns.regionalDomainName = 'regional.domainname.com'
28 |
29 | const options = { stage: 'staging' }
30 | const plugin = new Plugin(serverless, options)
31 | var regionalDomainName = plugin.buildRegionalDomainName(['test', 'thing', 'com'])
32 | expect(regionalDomainName).toBe('regional.domainname.com')
33 | })
34 |
35 | it('will build regional domain name', () => {
36 | const serverless = createServerlessStub()
37 | const options = { stage: 'staging' }
38 | const plugin = new Plugin(serverless, options)
39 | var regionalDomainName = plugin.buildRegionalDomainName(['test', 'thing', 'com'])
40 | expect(regionalDomainName).toBe('test-staging.thing.com')
41 | })
42 |
43 | it('will setup api regional domain settings from explicit settings', async () => {
44 | const serverless = {
45 | service: {
46 | custom: {
47 | dns: {
48 | regionalDomainName: 'regional.domainname.com',
49 | 'us-east-1': {
50 | acmCertificateArn: 'test-certificate'
51 | }
52 | }
53 | }
54 | }
55 | }
56 | const options = { stage: 'staging', region: 'us-east-1' }
57 | const plugin = new Plugin(serverless, options)
58 | plugin.regionalDomainName = 'regional.domainname.com'
59 |
60 | const resources = {
61 | Resources: { ApiRegionalDomainName: { Properties: {} } }
62 | }
63 |
64 | await plugin.prepareApiRegionalDomainSettings(resources)
65 |
66 | expect(resources.Resources.ApiRegionalDomainName.Properties.DomainName).toBe(
67 | 'regional.domainname.com'
68 | )
69 | expect(resources.Resources.ApiRegionalDomainName.Properties.RegionalCertificateArn).toBe(
70 | 'test-certificate'
71 | )
72 | })
73 |
74 | it('will retrieve certificate if not set', async () => {
75 | const serverless = createServerlessStub()
76 | const options = { stage: 'staging', region: 'us-east-1' }
77 | const plugin = new Plugin(serverless, options)
78 | plugin.getCertArnFromHostName = () => {
79 | return Promise.resolve('test-cert-arn')
80 | }
81 |
82 | const resources = {
83 | Resources: { ApiRegionalDomainName: { Properties: {} } }
84 | }
85 |
86 | await plugin.prepareApiRegionalDomainSettings(resources)
87 |
88 | expect(resources.Resources.ApiRegionalDomainName.Properties.RegionalCertificateArn).toBe(
89 | 'test-cert-arn'
90 | )
91 | })
92 |
93 | it('will set API regional base path defaults', async () => {
94 | const serverless = createServerlessStub()
95 | const options = { stage: 'staging', region: 'us-east-1' }
96 | const plugin = new Plugin(serverless, options)
97 |
98 | const resources = {
99 | Resources: {
100 | ApiGatewayStubDeployment: { Properties: {} },
101 | ApiRegionalBasePathMapping: { Properties: {} }
102 | }
103 | }
104 |
105 | await plugin.prepareApiRegionalBasePathMapping(resources)
106 |
107 | expect(resources.Resources.ApiGatewayStubDeployment.DependsOn).toBe(
108 | 'ApiGatewayMethodProxyVarAny'
109 | )
110 | expect(resources.Resources.ApiGatewayStubDeployment.Properties.StageName).toBe('staging')
111 | expect(resources.Resources.ApiRegionalBasePathMapping.Properties.Stage).toBe('staging')
112 | })
113 |
114 | it('will set API Gateway Stub DependsOn', async () => {
115 | const serverless = createServerlessStub()
116 | serverless.service.custom.gatewayMethodDependency = 'SomeMethodToDependOn'
117 |
118 | const options = { stage: 'staging', region: 'us-east-1' }
119 | const plugin = new Plugin(serverless, options)
120 |
121 | const resources = {
122 | Resources: {
123 | ApiGatewayStubDeployment: { Properties: {} },
124 | ApiRegionalBasePathMapping: { Properties: {} }
125 | }
126 | }
127 | await plugin.prepareApiRegionalBasePathMapping(resources)
128 |
129 | expect(resources.Resources.ApiGatewayStubDeployment.DependsOn).toBe('SomeMethodToDependOn')
130 | })
131 |
132 | it('will set API regional endpoint', async () => {
133 | const serverless = createServerlessStub()
134 | const options = { stage: 'staging', region: 'us-east-1' }
135 | const plugin = new Plugin(serverless, options)
136 | plugin.hostName = 'example.com'
137 |
138 | const resources = {
139 | Resources: {
140 | ApiRegionalEndpointRecord: { Properties: {} }
141 | },
142 | Outputs: { RegionalEndpoint: { Value: { ['Fn::Join']: ['', '', ''] } } }
143 | }
144 |
145 | await plugin.prepareApiRegionalEndpointRecord(resources)
146 |
147 | expect(resources.Resources.ApiRegionalEndpointRecord.Properties.HostedZoneName).toBe(
148 | 'example.com.'
149 | )
150 | expect(resources.Resources.ApiRegionalEndpointRecord.Properties.HostedZoneId).toBeUndefined()
151 | expect(resources.Resources.ApiRegionalEndpointRecord.Properties.Region).toBe('us-east-1')
152 | expect(resources.Resources.ApiRegionalEndpointRecord.Properties.SetIdentifier).toBe('us-east-1')
153 | })
154 |
155 | it('will set API regional endpoint hosted zone ID if present', async () => {
156 | const serverless = createServerlessStub()
157 | serverless.service.custom.dns.hostedZoneId = 'test-hosted-zone-id'
158 | const options = { stage: 'staging', region: 'us-east-1' }
159 | const plugin = new Plugin(serverless, options)
160 | plugin.hostName = 'example.com'
161 |
162 | const resources = {
163 | Resources: {
164 | ApiRegionalEndpointRecord: { Properties: {} }
165 | },
166 | Outputs: { RegionalEndpoint: { Value: { ['Fn::Join']: ['', '', ''] } } }
167 | }
168 |
169 | await plugin.prepareApiRegionalEndpointRecord(resources)
170 |
171 | expect(resources.Resources.ApiRegionalEndpointRecord.Properties.HostedZoneName).toBeUndefined()
172 | expect(resources.Resources.ApiRegionalEndpointRecord.Properties.HostedZoneId).toBe(
173 | 'test-hosted-zone-id'
174 | )
175 | expect(resources.Resources.ApiRegionalEndpointRecord.Properties.Region).toBe('us-east-1')
176 | expect(resources.Resources.ApiRegionalEndpointRecord.Properties.SetIdentifier).toBe('us-east-1')
177 | })
178 |
179 | it('will set API regional health check to default', async () => {
180 | const serverless = createServerlessStub()
181 | const options = { stage: 'staging', region: 'us-east-1' }
182 | const plugin = new Plugin(serverless, options)
183 |
184 | const resources = {
185 | Resources: {
186 | ApiRegionalEndpointRecord: { Properties: {} },
187 | ApiRegionalHealthCheck: { Properties: { HealthCheckConfig: {} } }
188 | }
189 | }
190 |
191 | await plugin.prepareApiRegionalHealthCheck(resources)
192 | expect(resources.Resources.ApiRegionalEndpointRecord.Properties.HealthCheckId).toBeUndefined()
193 | expect(
194 | resources.Resources.ApiRegionalHealthCheck.Properties.HealthCheckConfig.ResourcePath
195 | ).toBe('/staging/healthcheck')
196 | })
197 |
198 | it('will set API regional health check to specified path', async () => {
199 | const serverless = createServerlessStub()
200 | serverless.service.custom.dns.healthCheckResourcePath = '/test/resource/path'
201 | const options = { stage: 'staging', region: 'us-east-1' }
202 | const plugin = new Plugin(serverless, options)
203 |
204 | const resources = {
205 | Resources: {
206 | ApiRegionalEndpointRecord: { Properties: {} },
207 | ApiRegionalHealthCheck: { Properties: { HealthCheckConfig: {} } }
208 | }
209 | }
210 |
211 | await plugin.prepareApiRegionalHealthCheck(resources)
212 | expect(resources.Resources.ApiRegionalEndpointRecord.Properties.HealthCheckId).toBeUndefined()
213 | expect(
214 | resources.Resources.ApiRegionalHealthCheck.Properties.HealthCheckConfig.ResourcePath
215 | ).toBe('/test/resource/path')
216 | })
217 |
218 | it('will set API regional health check ID to specified value', async () => {
219 | const serverless = createServerlessStub()
220 | serverless.service.custom.dns['us-east-1'] = { healthCheckId: 'test-health-check-id' }
221 | const options = { stage: 'staging', region: 'us-east-1' }
222 | const plugin = new Plugin(serverless, options)
223 |
224 | const resources = {
225 | Resources: {
226 | ApiRegionalEndpointRecord: { Properties: {} },
227 | ApiRegionalHealthCheck: { Properties: { HealthCheckConfig: {} } }
228 | }
229 | }
230 |
231 | await plugin.prepareApiRegionalHealthCheck(resources)
232 | expect(resources.Resources.ApiRegionalEndpointRecord.Properties.HealthCheckId).toBe(
233 | 'test-health-check-id'
234 | )
235 | expect(resources.Resources.ApiRegionalHealthCheck).toBeUndefined()
236 | })
237 |
238 | it('will api regional failover settings from explicit settings', async () => {
239 | const serverless = {
240 | service: {
241 | custom: {
242 | dns: {
243 | 'us-east-1': { failover: 'PRIMARY' }
244 | }
245 | }
246 | }
247 | }
248 | const options = { stage: 'staging', region: 'us-east-1' }
249 | const plugin = new Plugin(serverless, options)
250 | plugin.regionalDomainName = 'regional.domainname.com'
251 |
252 | const resources = {
253 | Resources: {
254 | ApiRegionalEndpointRecord: { Properties: {} }
255 | },
256 | Outputs: { RegionalEndpoint: { Value: { ['Fn::Join']: ['', '', ''] } } }
257 | }
258 |
259 | await plugin.prepareApiRegionalEndpointRecord(resources)
260 | expect(resources.Resources.ApiRegionalEndpointRecord.Properties.Failover).toBe('PRIMARY')
261 | })
262 | })
263 |
--------------------------------------------------------------------------------