From 65a697bbf8cc283fd7e34a2ac390c41a9ede9ccf Mon Sep 17 00:00:00 2001 From: Hunter Date: Mon, 8 Jun 2020 21:48:27 -0500 Subject: [PATCH 1/4] add autoDomain and autoDomainWaitFor option --- README.md | 6 + src/index.ts | 33 +++- src/types.ts | 2 + src/utils.ts | 1 + test/integration-tests/auto-domain/handler.js | 16 ++ .../auto-domain/serverless.yml | 27 +++ test/unit-tests/index.test.ts | 169 ++++++++++++++++++ 7 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 test/integration-tests/auto-domain/handler.js create mode 100644 test/integration-tests/auto-domain/serverless.yml diff --git a/README.md b/README.md index 60082ff2..3a8aa920 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ custom: endpointType: 'regional' securityPolicy: tls_1_2 apiType: rest + autoDomain: false ``` Multiple API types mapped to different domains can also be supported with the following structure. The key is the API Gateway API type. @@ -86,6 +87,7 @@ custom: createRoute53Record: true endpointType: 'regional' securityPolicy: tls_1_2 + autoDomain: false http: domainName: http.serverless.foo.com stage: ci @@ -94,6 +96,7 @@ custom: createRoute53Record: true endpointType: 'regional' securityPolicy: tls_1_2 + autoDomain: false websocket: domainName: ws.serverless.foo.com stage: ci @@ -102,6 +105,7 @@ custom: createRoute53Record: true endpointType: 'regional' securityPolicy: tls_1_2 + autoDomain: false ``` | Parameter Name | Default Value | Description | @@ -119,6 +123,8 @@ custom: | enabled | true | Sometimes there are stages for which is not desired to have custom domain names. This flag allows the developer to disable the plugin for such cases. Accepts either `boolean` or `string` values and defaults to `true` for backwards compatibility. | securityPolicy | tls_1_2 | The security policy to apply to the custom domain name. Accepts `tls_1_0` or `tls_1_2`| allowPathMatching | false | When updating an existing api mapping this will match on the basePath instead of the API ID to find existing mappings for an upsate. This should only be used when changing API types. For example, migrating a REST API to an HTTP API. See Changing API Types for more information. | +| autoDomain | `false` | Toggles whether or not the plugin will run `create_domain/delete_domain` as part of `sls deploy/remove` so that multiple commands are not required. | +| autoDomainWaitFor | `120` | How long to wait for create_domain to finish before starting deployment if domain does not exist immediately. | ## Running diff --git a/src/index.ts b/src/index.ts index 40bd6787..ddf65eb2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import DomainConfig = require("./DomainConfig"); import DomainInfo = require("./DomainInfo"); import Globals from "./Globals"; import { ServerlessInstance, ServerlessOptions } from "./types"; -import {getAWSPagedResults, throttledCall} from "./utils"; +import {getAWSPagedResults, sleep, throttledCall} from "./utils"; const certStatuses = ["PENDING_VALIDATION", "ISSUED", "INACTIVE"]; @@ -54,7 +54,7 @@ class ServerlessCustomDomain { this.hooks = { "after:deploy:deploy": this.hookWrapper.bind(this, this.setupBasePathMappings), "after:info:info": this.hookWrapper.bind(this, this.domainSummaries), - "before:deploy:deploy": this.hookWrapper.bind(this, this.updateCloudFormationOutputs), + "before:deploy:deploy": this.hookWrapper.bind(this, this.createOrGetDomainForCfOutputs), "before:remove:remove": this.hookWrapper.bind(this, this.removeBasePathMappings), "create_domain:create": this.hookWrapper.bind(this, this.createDomains), "delete_domain:delete": this.hookWrapper.bind(this, this.deleteDomains), @@ -130,12 +130,31 @@ class ServerlessCustomDomain { } /** - * Lifecycle function to add domain info to the CloudFormation stack's Outputs + * Lifecycle function to createDomain before deploy and add domain info to the CloudFormation stack's Outputs */ - public async updateCloudFormationOutputs(): Promise { + public async createOrGetDomainForCfOutputs(): Promise { + const autoDomain = this.serverless.service.custom.customDomain.autoDomain; + if (autoDomain === true) { + this.serverless.cli.log("Creating domain name before deploy."); + await this.createDomains(); + } await this.getDomainInfo(); + if (autoDomain === true) { + const atLeastOneDoesNotExist = this.domains.some((domain) => !domain.domainInfo); + if (atLeastOneDoesNotExist === true) { + const waitFor = + parseInt(this.serverless.service.custom.customDomain.autoDomainWaitFor, 10) + || 120; + this.serverless.cli.log(`Waiting ${waitFor} seconds before starting deployment + because first time creating domain`); + + await sleep(waitFor); + await this.getDomainInfo(); + } + } + await Promise.all(this.domains.map(async (domain) => { this.addOutputs(domain); })); @@ -200,6 +219,12 @@ class ServerlessCustomDomain { } } })); + + const autoDomain = this.serverless.service.custom.customDomain.autoDomain; + if (autoDomain === true) { + this.serverless.cli.log("Deleting domain name after removing base path mapping."); + await this.deleteDomains(); + } } /** diff --git a/src/types.ts b/src/types.ts index 0e8c089c..34059e66 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,6 +25,8 @@ export interface ServerlessInstance { // tslint:disable-line hostedZonePrivate: boolean | undefined, enabled: boolean | string | undefined, securityPolicy: string | undefined, + autoDomain: boolean | undefined, + autoDomainWaitFor: string | undefined, }, }, }; diff --git a/src/utils.ts b/src/utils.ts index ce960381..f9fef870 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -76,6 +76,7 @@ async function sleep(seconds) { } export { + sleep, getAWSPagedResults, throttledCall, }; diff --git a/test/integration-tests/auto-domain/handler.js b/test/integration-tests/auto-domain/handler.js new file mode 100644 index 00000000..1bd222d6 --- /dev/null +++ b/test/integration-tests/auto-domain/handler.js @@ -0,0 +1,16 @@ +"use strict"; + +module.exports.helloWorld = (event, context, callback) => { + const response = { + statusCode: 200, + headers: { + "Access-Control-Allow-Origin": "*", // Required for CORS support to work + }, + body: JSON.stringify({ + message: "Go Serverless v1.0! Your function executed successfully!", + input: event, + }), + }; + + callback(null, response); +}; diff --git a/test/integration-tests/auto-domain/serverless.yml b/test/integration-tests/auto-domain/serverless.yml new file mode 100644 index 00000000..1f7c323f --- /dev/null +++ b/test/integration-tests/auto-domain/serverless.yml @@ -0,0 +1,27 @@ +# Deploying should be idempotent +service: auto-domain-${opt:RANDOM_STRING} +provider: + name: aws + runtime: nodejs12.x + region: us-west-2 + stage: test +functions: + helloWorld: + handler: handler.helloWorld + events: + - http: + path: hello-world + method: get + cors: true +plugins: + - serverless-domain-manager +custom: + customDomain: + domainName: auto-domain-${opt:RANDOM_STRING}.${env:TEST_DOMAIN} + basePath: '' + autoDomain: true + autoDomainWaitFor: 120 + +package: + exclude: + - node_modules/** diff --git a/test/unit-tests/index.test.ts b/test/unit-tests/index.test.ts index e8412b31..d338253c 100644 --- a/test/unit-tests/index.test.ts +++ b/test/unit-tests/index.test.ts @@ -64,6 +64,8 @@ const constructPlugin = (customDomainOptions) => { custom: { customDomain: { apiType: customDomainOptions.apiType, + autoDomain: customDomainOptions.autoDomain, + autoDomainWaitFor: customDomainOptions.autoDomainWaitFor, basePath: customDomainOptions.basePath, certificateArn: customDomainOptions.certificateArn, certificateName: customDomainOptions.certificateName, @@ -1576,4 +1578,171 @@ describe("Custom Domain Plugin", () => { AWS.restore(); }); }); + + describe("autoDomain deploy", () => { + it("Should be disabled by default", () => { + const plugin = constructPlugin({ domainName: "test_domain" }); + plugin.initializeVariables(); + expect(plugin.serverless.service.custom.customDomain.autoDomain).to.equal(undefined); + }); + + it("createOrGetDomainForCfOutputs should call createDomain when autoDomain is true", async () => { + AWS.mock("ApiGatewayV2", "getDomainName", (params, callback) => { + callback(null, params); + }); + const plugin = constructPlugin({ + autoDomain: true, + basePath: "test_basepath", + createRoute53Record: false, + domainName: "test_domain", + restApiId: "test_rest_api_id", + }); + plugin.initializeVariables(); + + plugin.apigateway = new aws.APIGateway(); + plugin.apigatewayV2 = new aws.ApiGatewayV2(); + plugin.cloudformation = new aws.CloudFormation(); + + plugin.domains[0].apiMapping = {ApiMappingId: "test_mapping_id"}; + + const spy = chai.spy.on(plugin.apigatewayV2, "getDomainName"); + + await plugin.createOrGetDomainForCfOutputs(); + + expect(plugin.serverless.service.custom.customDomain.autoDomain).to.equal(true); + expect(spy).to.have.been.called(); + }); + + it("createOrGetDomainForCfOutputs should not call createDomain when autoDomain is not true", async () => { + AWS.mock("ApiGatewayV2", "getDomainName", (params, callback) => { + callback(null, params); + }); + + const plugin = constructPlugin({ + autoDomain: false, + basePath: "test_basepath", + createRoute53Record: false, + domainName: "test_domain", + restApiId: "test_rest_api_id", + }); + plugin.initializeVariables(); + + plugin.apigateway = new aws.APIGateway(); + plugin.apigatewayV2 = new aws.ApiGatewayV2(); + plugin.cloudformation = new aws.CloudFormation(); + + plugin.domains[0].apiMapping = {ApiMappingId: "test_mapping_id"}; + + const spy1 = chai.spy.on(plugin.apigateway, "createDomainName"); + const spy2 = chai.spy.on(plugin.apigatewayV2, "createDomainName"); + + await plugin.createOrGetDomainForCfOutputs(); + + expect(plugin.serverless.service.custom.customDomain.autoDomain).to.equal(false); + expect(spy1).to.have.not.been.called(); + expect(spy2).to.have.not.been.called(); + }); + + it("removeBasePathMapping should call deleteDomain when autoDomain is true", async () => { + AWS.mock("CloudFormation", "describeStackResource", (params, callback) => { + callback(null, { + StackResourceDetail: + { + LogicalResourceId: "ApiGatewayRestApi", + PhysicalResourceId: "test_rest_api_id", + }, + }); + }); + AWS.mock("ApiGatewayV2", "getApiMappings", (params, callback) => { + callback(null, { + Items: [ + { ApiId: "test_rest_api_id", MappingKey: "test", ApiMappingId: "test_mapping_id", Stage: "test" }, + ], + }); + }); + AWS.mock("ApiGatewayV2", "deleteApiMapping", (params, callback) => { + callback(null, params); + }); + AWS.mock("ApiGatewayV2", "deleteDomainName", (params, callback) => { + callback(null, params); + }); + AWS.mock("ApiGatewayV2", "getDomainName", (params, callback) => { + callback(null, params); + }); + + const plugin = constructPlugin({ + autoDomain: true, + basePath: "test_basepath", + createRoute53Record: false, + domainName: "test_domain", + restApiId: "test_rest_api_id", + }); + plugin.initializeVariables(); + + plugin.apigatewayV2 = new aws.ApiGatewayV2(); + plugin.cloudformation = new aws.CloudFormation(); + + plugin.domains[0].apiMapping = {ApiMappingId: "test_mapping_id"}; + + const spy = chai.spy.on(plugin.apigatewayV2, "deleteDomainName"); + + await plugin.removeBasePathMappings(); + + expect(plugin.serverless.service.custom.customDomain.autoDomain).to.equal(true); + expect(spy).to.have.been.called.with({DomainName: "test_domain"}); + }); + + it("removeBasePathMapping should not call deleteDomain when autoDomain is not true", async () => { + AWS.mock("CloudFormation", "describeStackResource", (params, callback) => { + callback(null, { + StackResourceDetail: + { + LogicalResourceId: "ApiGatewayRestApi", + PhysicalResourceId: "test_rest_api_id", + }, + }); + }); + AWS.mock("ApiGatewayV2", "getApiMappings", (params, callback) => { + callback(null, { + Items: [ + { ApiId: "test_rest_api_id", MappingKey: "test", ApiMappingId: "test_mapping_id", Stage: "test" }, + ], + }); + }); + AWS.mock("ApiGatewayV2", "deleteApiMapping", (params, callback) => { + callback(null, params); + }); + AWS.mock("ApiGatewayV2", "deleteDomainName", (params, callback) => { + callback(null, params); + }); + AWS.mock("ApiGatewayV2", "getDomainName", (params, callback) => { + callback(null, params); + }); + + const plugin = constructPlugin({ + autoDomain: false, + basePath: "test_basepath", + createRoute53Record: false, + domainName: "test_domain", + restApiId: "test_rest_api_id", + }); + plugin.initializeVariables(); + + plugin.apigatewayV2 = new aws.ApiGatewayV2(); + plugin.cloudformation = new aws.CloudFormation(); + + plugin.domains[0].apiMapping = {ApiMappingId: "test_mapping_id"}; + + const spy = chai.spy.on(plugin.apigatewayV2, "deleteDomainName"); + + await plugin.removeBasePathMappings(); + + expect(plugin.serverless.service.custom.customDomain.autoDomain).to.equal(false); + expect(spy).to.have.not.been.called(); + }); + + afterEach(() => { + consoleOutput = []; + }); + }); }); From fb0f464fa33ce072e8dbeb9e624a9a1a8adcaea9 Mon Sep 17 00:00:00 2001 From: Hunter Date: Mon, 8 Jun 2020 21:56:32 -0500 Subject: [PATCH 2/4] updates --- README.md | 3 --- test/integration-tests/auto-domain/serverless.yml | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/README.md b/README.md index 3a8aa920..6eb0f6b2 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,6 @@ custom: createRoute53Record: true endpointType: 'regional' securityPolicy: tls_1_2 - autoDomain: false http: domainName: http.serverless.foo.com stage: ci @@ -96,7 +95,6 @@ custom: createRoute53Record: true endpointType: 'regional' securityPolicy: tls_1_2 - autoDomain: false websocket: domainName: ws.serverless.foo.com stage: ci @@ -105,7 +103,6 @@ custom: createRoute53Record: true endpointType: 'regional' securityPolicy: tls_1_2 - autoDomain: false ``` | Parameter Name | Default Value | Description | diff --git a/test/integration-tests/auto-domain/serverless.yml b/test/integration-tests/auto-domain/serverless.yml index 1f7c323f..594aa5a6 100644 --- a/test/integration-tests/auto-domain/serverless.yml +++ b/test/integration-tests/auto-domain/serverless.yml @@ -1,4 +1,4 @@ -# Deploying should be idempotent +# create_domain should be run as part of deployment service: auto-domain-${opt:RANDOM_STRING} provider: name: aws From 805d6209bf4fcab837a4f86d021579e4d50a5b91 Mon Sep 17 00:00:00 2001 From: Hunter Date: Mon, 15 Jun 2020 10:40:50 -0500 Subject: [PATCH 3/4] add to test cases --- test/integration-tests/integration.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/integration-tests/integration.test.ts b/test/integration-tests/integration.test.ts index 6b52c88a..fd6a3204 100644 --- a/test/integration-tests/integration.test.ts +++ b/test/integration-tests/integration.test.ts @@ -21,6 +21,14 @@ const RANDOM_STRING = randomstring.generate({ const TEMP_DIR = `~/tmp/domain-manager-test-${RANDOM_STRING}`; const testCases = [ + { + testBasePath: "(none)", + testDescription: "Creates domain as part of deploy", + testDomain: `auto-domain-${RANDOM_STRING}.${TEST_DOMAIN}`, + testEndpoint: "EDGE", + testFolder: "auto-domain", + testStage: "dev", + }, { testBasePath: "(none)", testDescription: "Enabled with default values", From 2cee458ee52468f63c1a82f78c9b829a7acab808 Mon Sep 17 00:00:00 2001 From: Hunter Date: Fri, 19 Jun 2020 11:48:21 -0500 Subject: [PATCH 4/4] add polling interval --- src/index.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index ddf65eb2..c4abf0fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -142,15 +142,17 @@ class ServerlessCustomDomain { await this.getDomainInfo(); if (autoDomain === true) { - const atLeastOneDoesNotExist = this.domains.some((domain) => !domain.domainInfo); - if (atLeastOneDoesNotExist === true) { - const waitFor = - parseInt(this.serverless.service.custom.customDomain.autoDomainWaitFor, 10) - || 120; - this.serverless.cli.log(`Waiting ${waitFor} seconds before starting deployment - because first time creating domain`); - - await sleep(waitFor); + const atLeastOneDoesNotExist = () => this.domains.some((domain) => !domain.domainInfo); + const maxWaitFor = parseInt(this.serverless.service.custom.customDomain.autoDomainWaitFor, 10) || 120; + const pollInterval = 3; + for (let i = 0; i * pollInterval < maxWaitFor && atLeastOneDoesNotExist() === true; i++) { + this.serverless.cli.log(` + Poll #${i + 1}: polling every ${pollInterval} seconds + for domain to exist or until ${maxWaitFor} seconds + have elapsed before starting deployment + `); + + await sleep(pollInterval); await this.getDomainInfo(); } }