├── .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 | [![Build Status](https://travis-ci.com/unbill/serverless-multi-region-plugin.svg?branch=master)](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 | --------------------------------------------------------------------------------