Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for Multiple domains #327

Merged
merged 13 commits into from Sep 23, 2020
16 changes: 16 additions & 0 deletions README.md
Expand Up @@ -105,6 +105,21 @@ custom:
securityPolicy: tls_1_2
```

Or for multiple domains

```yaml
custom:
customDomains:
- http:
domainName: http-api-${opt:RANDOM_STRING}.${env:TEST_DOMAIN}
basePath: ''
endpointType: 'regional'
- http:
domainName: http-api-${opt:RANDOM_STRING}.${env:TEST_DOMAIN}.foo
basePath: ''
endpointType: 'regional'
```

| Parameter Name | Default Value | Description |
| --- | --- | --- |
| domainName _(Required)_ | | The domain name to be created in API Gateway and Route53 (if enabled) for this API. |
Expand All @@ -124,6 +139,7 @@ allowPathMatching | false | When updating an existing api mapping this will matc
| autoDomainWaitFor | `120` | How long to wait for create_domain to finish before starting deployment if domain does not exist immediately. |



## Running

To create the custom domain:
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion src/DomainConfig.ts
Expand Up @@ -5,9 +5,12 @@
import * as AWS from "aws-sdk"; // imported for Types
import DomainInfo = require("./DomainInfo");
import Globals from "./Globals";
import { CustomDomain } from "./types";

class DomainConfig {

public acm: any;

public givenDomainName: string;
public basePath: string | undefined;
public stage: string | undefined;
Expand All @@ -20,13 +23,15 @@ class DomainConfig {
public hostedZonePrivate: boolean | undefined;
public enabled: boolean | string | undefined;
public securityPolicy: string | undefined;
public autoDomain: boolean | undefined;
public autoDomainWaitFor: string | undefined;

public domainInfo: DomainInfo | undefined;
public apiId: string | undefined;
public apiMapping: AWS.ApiGatewayV2.GetApiMappingResponse;
public allowPathMatching: boolean | false;

constructor(config: any) {
constructor(config: CustomDomain) {

this.enabled = this.evaluateEnabled(config.enabled);
this.givenDomainName = config.domainName;
Expand All @@ -37,6 +42,8 @@ class DomainConfig {
this.hostedZoneId = config.hostedZoneId;
this.hostedZonePrivate = config.hostedZonePrivate;
this.allowPathMatching = config.allowPathMatching;
this.autoDomain = config.autoDomain;
this.autoDomainWaitFor = config.autoDomainWaitFor;

let basePath = config.basePath;
if (basePath == null || basePath.trim() === "") {
Expand Down Expand Up @@ -70,6 +77,11 @@ class DomainConfig {
throw new Error(`${securityPolicyDefault} is not a supported securityPolicy, use tls_1_0 or tls_1_2.`);
}
this.securityPolicy = tlsVersionToUse;

const region = this.endpointType === Globals.endpointTypes.regional ?
Globals.serverless.providers.aws.getRegion() : "us-east-1";
const acmCredentials = Object.assign({}, Globals.serverless.providers.aws.getCredentials(), { region });
this.acm = new Globals.serverless.providers.aws.sdk.ACM(acmCredentials);
}

/**
Expand Down
103 changes: 51 additions & 52 deletions src/index.ts
Expand Up @@ -4,7 +4,7 @@ import chalk from "chalk";
import DomainConfig = require("./DomainConfig");
import DomainInfo = require("./DomainInfo");
import Globals from "./Globals";
import { ServerlessInstance, ServerlessOptions } from "./types";
import { CustomDomain, ServerlessInstance, ServerlessOptions } from "./types";
import {getAWSPagedResults, sleep, throttledCall} from "./utils";

const certStatuses = ["PENDING_VALIDATION", "ISSUED", "INACTIVE"];
Expand All @@ -15,8 +15,6 @@ class ServerlessCustomDomain {
public apigateway: any;
public apigatewayV2: any;
public route53: any;
public acm: any;
public acmRegion: string;
public cloudformation: any;

// Serverless specific properties
Expand Down Expand Up @@ -133,29 +131,31 @@ class ServerlessCustomDomain {
* Lifecycle function to createDomain before deploy and add domain info to the CloudFormation stack's Outputs
*/
public async createOrGetDomainForCfOutputs(): Promise<void> {
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();
await Promise.all(this.domains.map(async (domain) => {
const autoDomain = domain.autoDomain;
if (autoDomain === true) {
this.serverless.cli.log("Creating domain name before deploy.");
await this.createDomains();
}

if (autoDomain === true) {
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();
await this.getDomainInfo();

if (autoDomain === true) {
const atLeastOneDoesNotExist = () => this.domains.some((d) => !d.domainInfo);
const maxWaitFor = parseInt(domain.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();
}
}
}
}));

await Promise.all(this.domains.map(async (domain) => {
this.addOutputs(domain);
Expand Down Expand Up @@ -220,13 +220,13 @@ class ServerlessCustomDomain {
for domain ${domain.givenDomainName}`);
}
}
}));

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();
}
const autoDomain = domain.autoDomain;
if (autoDomain === true) {
this.serverless.cli.log("Deleting domain name after removing base path mapping.");
await this.deleteDomains();
}
}));
}

/**
Expand Down Expand Up @@ -254,7 +254,8 @@ class ServerlessCustomDomain {

// Make sure customDomain configuration exists, stop if not
if (typeof this.serverless.service.custom === "undefined"
|| typeof this.serverless.service.custom.customDomain === "undefined") {
|| ( typeof this.serverless.service.custom.customDomain === "undefined"
&& typeof this.serverless.service.custom.customDomains === "undefined" )) {
throw new Error("serverless-domain-manager: Plugin configuration is missing.");
}

Expand All @@ -269,31 +270,29 @@ class ServerlessCustomDomain {
// Loop over the domain configurations and populate the domains array with DomainConfigs
this.domains = [];

// If the key of the item in config is an api type it is using per api type domain structure
if (Globals.apiTypes[Object.keys(this.serverless.service.custom.customDomain)[0]]) {
for (const configApiType in this.serverless.service.custom.customDomain) {
if (Globals.apiTypes[configApiType]) { // If statement check to follow tslint
this.serverless.service.custom.customDomain[configApiType].apiType = configApiType;
this.domains.push(new DomainConfig(this.serverless.service.custom.customDomain[configApiType]));
} else {
throw Error(`Error: Invalid API Type, ${configApiType}`);
const customDomains: CustomDomain[] = this.serverless.service.custom.customDomains ?
this.serverless.service.custom.customDomains :
[ this.serverless.service.custom.customDomain ];

customDomains.forEach((d) => {
// If the key of the item in config is an api type it is using per api type domain structure
if (Globals.apiTypes[Object.keys(d)[0]]) {
for (const configApiType in d) {
if (Globals.apiTypes[configApiType]) { // If statement check to follow tslint
d[configApiType].apiType = configApiType;
this.domains.push(new DomainConfig(d[configApiType]));
} else {
throw Error(`Error: Invalid API Type, ${configApiType}`);
}
}
} else { // Default to single domain config
this.domains.push(new DomainConfig(d));
}
} else { // Default to single domain config
this.domains.push(new DomainConfig(this.serverless.service.custom.customDomain));
}
});

// Filter inactive domains
this.domains = this.domains.filter((domain) => domain.enabled);

// Set ACM Region on the domain configs
for (const dc of this.domains) {
this.acmRegion = dc.endpointType === Globals.endpointTypes.regional ?
this.serverless.providers.aws.getRegion() : "us-east-1";
const acmCredentials = Object.assign({}, credentials, { region: this.acmRegion });
this.acm = new this.serverless.providers.aws.sdk.ACM(acmCredentials);
}

// Validate the domain configurations
this.validateDomainConfigs();
}
Expand Down Expand Up @@ -343,7 +342,7 @@ class ServerlessCustomDomain {

try {
const certificates = await getAWSPagedResults(
this.acm,
domain.acm,
"listCertificates",
"CertificateSummaryList",
"NextToken",
Expand Down Expand Up @@ -534,7 +533,7 @@ class ServerlessCustomDomain {
public async getRoute53HostedZoneId(domain: DomainConfig): Promise<string> {
if (domain.hostedZoneId) {
this.serverless.cli.log(
`Selected specific hostedZoneId ${this.serverless.service.custom.customDomain.hostedZoneId}`);
`Selected specific hostedZoneId ${domain.hostedZoneId}`);
return domain.hostedZoneId;
}

Expand Down
36 changes: 20 additions & 16 deletions src/types.ts
@@ -1,3 +1,21 @@
export interface CustomDomain { // tslint:disable-line
domainName: string;
basePath: string | undefined;
stage: string | undefined;
certificateName: string | undefined;
certificateArn: string | undefined;
createRoute53Record: boolean | undefined;
endpointType: string | undefined;
apiType: string | undefined;
hostedZoneId: string | undefined;
hostedZonePrivate: boolean | undefined;
enabled: boolean | string | undefined;
securityPolicy: string | undefined;
autoDomain: boolean | undefined;
autoDomainWaitFor: string | undefined;
allowPathMatching: boolean | undefined;
}

export interface ServerlessInstance { // tslint:disable-line
service: {
service: string
Expand All @@ -12,22 +30,8 @@ export interface ServerlessInstance { // tslint:disable-line
},
}
custom: {
customDomain: {
domainName: string,
basePath: string | undefined,
stage: string | undefined,
certificateName: string | undefined,
certificateArn: string | undefined,
createRoute53Record: boolean | undefined,
endpointType: string | undefined,
apiType: string | undefined,
hostedZoneId: string | undefined,
hostedZonePrivate: boolean | undefined,
enabled: boolean | string | undefined,
securityPolicy: string | undefined,
autoDomain: boolean | undefined,
autoDomainWaitFor: string | undefined,
},
customDomain?: CustomDomain | undefined,
customDomains?: CustomDomain[] | undefined,
},
};
providers: {
Expand Down
16 changes: 16 additions & 0 deletions test/integration-tests/http-api-multiple/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);
};
29 changes: 29 additions & 0 deletions test/integration-tests/http-api-multiple/serverless.yml
@@ -0,0 +1,29 @@
service: http-api-${opt:RANDOM_STRING}
provider:
name: aws
runtime: nodejs12.x
region: us-west-2
stage: dev
functions:
helloWorld:
handler: handler.connect
events:
- httpApi:
method: GET
path: /hello-world
plugins:
- serverless-domain-manager
custom:
customDomains:
- http:
domainName: http-api-${opt:RANDOM_STRING}.${env:TEST_DOMAIN}
basePath: ''
endpointType: 'regional'
- http:
domainName: http-api-${opt:RANDOM_STRING}.${env:TEST_DOMAIN}.foo
basePath: ''
endpointType: 'regional'

package:
exclude:
- node_modules/**
8 changes: 8 additions & 0 deletions test/integration-tests/integration.test.ts
Expand Up @@ -94,6 +94,14 @@ const testCases = [
testFolder: "http-api",
testStage: "$default",
},
{
testBasePath: "(none)",
testDescription: "Create HTTP API and domain name",
testDomain: `http-api-${RANDOM_STRING}.${TEST_DOMAIN}`,
testEndpoint: "REGIONAL",
testFolder: "http-api-multiple",
testStage: "$default",
},
{
testBasePath: "(none)",
testDescription: "Deploy regional domain with TLS 1.0",
Expand Down