serverless-domain-manager
Version:
Serverless plugin for managing custom domains with API Gateways.
474 lines (473 loc) • 23.5 kB
JavaScript
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
const ACMWrapper = require("./aws/acm-wrapper");
const CloudFormationWrapper = require("./aws/cloud-formation-wrapper");
const Route53Wrapper = require("./aws/route53-wrapper");
const S3Wrapper = require("./aws/s3-wrapper");
const DomainConfig = require("./models/domain-config");
const globals_1 = __importDefault(require("./globals"));
const utils_1 = require("./utils");
const APIGatewayV1Wrapper = require("./aws/api-gateway-v1-wrapper");
const APIGatewayV2Wrapper = require("./aws/api-gateway-v2-wrapper");
const logging_1 = __importDefault(require("./logging"));
const node_config_provider_1 = require("@smithy/node-config-provider");
const config_resolver_1 = require("@smithy/config-resolver");
const client_route_53_1 = require("@aws-sdk/client-route-53");
class ServerlessCustomDomain {
constructor(serverless, options, v3Utils) {
// Domain Manager specific properties
this.domains = [];
this.serverless = serverless;
globals_1.default.serverless = serverless;
this.options = options;
globals_1.default.options = options;
if (v3Utils === null || v3Utils === void 0 ? void 0 : v3Utils.log) {
globals_1.default.v3Utils = v3Utils;
}
/* eslint camelcase: ["error", {allow: ["create_domain", "delete_domain"]}] */
this.commands = {
create_domain: {
lifecycleEvents: [
"create",
"initialize"
],
usage: "Creates a domain using the domain name defined in the serverless file"
},
delete_domain: {
lifecycleEvents: [
"delete",
"initialize"
],
usage: "Deletes a domain using the domain name defined in the serverless file"
}
};
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.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)
};
}
/**
* Wrapper for lifecycle function, initializes variables and checks if enabled.
* @param lifecycleFunc lifecycle function that actually does desired action
*/
hookWrapper(lifecycleFunc) {
return __awaiter(this, void 0, void 0, function* () {
// check if `customDomain` or `customDomains` config exists
this.validateConfigExists();
// init config variables
this.initializeVariables();
// Validate the domain configurations
this.validateDomainConfigs();
// setup AWS resources
yield this.initSLSCredentials();
yield this.initAWSRegion();
yield this.initAWSResources();
// start of the legacy AWS SDK V2 creds support
// TODO: remove it in case serverless will add V3 support
const domain = this.domains[0];
if (domain) {
try {
yield this.getApiGateway(domain).getCustomDomain(domain);
}
catch (error) {
if (error.message.includes("Could not load credentials from any providers")) {
globals_1.default.credentials = this.serverless.providers.aws.getCredentials();
yield this.initAWSResources();
}
}
}
// end of the legacy AWS SDK V2 creds support
return lifecycleFunc.call(this);
});
}
/**
* Validate if the plugin config exists
*/
validateConfigExists() {
// Make sure customDomain configuration exists, stop if not
const config = this.serverless.service.custom;
const domainExists = config && typeof config.customDomain !== "undefined";
const domainsExists = config && typeof config.customDomains !== "undefined";
if (typeof config === "undefined" || (!domainExists && !domainsExists)) {
throw new Error(`${globals_1.default.pluginName}: Plugin configuration is missing.`);
}
}
/**
* Goes through custom domain property and initializes local variables and cloudformation template
*/
initializeVariables() {
const config = this.serverless.service.custom;
const domainConfig = config.customDomain ? [config.customDomain] : [];
const domainsConfig = config.customDomains || [];
const customDomains = domainConfig.concat(domainsConfig);
// Loop over the domain configurations and populate the domains array with DomainConfigs
this.domains = [];
customDomains.forEach((domain) => {
// If the key of the item in config is an API type then using per API type domain structure
let isTypeConfigFound = false;
Object.keys(globals_1.default.apiTypes).forEach((apiType) => {
const domainTypeConfig = domain[apiType];
if (domainTypeConfig) {
domainTypeConfig.apiType = apiType;
this.domains.push(new DomainConfig(domainTypeConfig));
isTypeConfigFound = true;
}
});
if (!isTypeConfigFound) {
this.domains.push(new DomainConfig(domain));
}
});
// Filter inactive domains
this.domains = this.domains.filter((domain) => domain.enabled);
}
/**
* Validates domain configs to make sure they are valid, ie HTTP api cannot be used with EDGE domain
*/
validateDomainConfigs() {
this.domains.forEach((domain) => {
if (domain.apiType === globals_1.default.apiTypes.rest) {
// No validation for REST API types
}
else if (domain.apiType === globals_1.default.apiTypes.http) {
// HTTP APIs do not support edge domains
if (domain.endpointType === globals_1.default.endpointTypes.edge) {
// https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-vs-rest.html
throw Error("'EDGE' endpointType is not compatible with HTTP APIs\n" +
"https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-vs-rest.html");
}
}
else if (domain.apiType === globals_1.default.apiTypes.websocket) {
// Websocket APIs do not support edge domains
if (domain.endpointType === globals_1.default.endpointTypes.edge) {
throw Error("'EDGE' endpointType is not compatible with WebSocket APIs");
}
}
});
}
/**
* Init AWS credentials based on sls `provider.profile`
*/
initSLSCredentials() {
return __awaiter(this, void 0, void 0, function* () {
const slsProfile = globals_1.default.options["aws-profile"] || globals_1.default.serverless.service.provider.profile;
globals_1.default.credentials = slsProfile ? yield globals_1.default.getProfileCreds(slsProfile) : null;
});
}
/**
* Init AWS current region based on Node options
*/
initAWSRegion() {
return __awaiter(this, void 0, void 0, function* () {
try {
globals_1.default.currentRegion = yield (0, node_config_provider_1.loadConfig)(config_resolver_1.NODE_REGION_CONFIG_OPTIONS, config_resolver_1.NODE_REGION_CONFIG_FILE_OPTIONS)();
}
catch (err) {
logging_1.default.logInfo("Node region was not found.");
}
});
}
/**
* Setup AWS resources
*/
initAWSResources() {
return __awaiter(this, void 0, void 0, function* () {
this.apiGatewayV1Wrapper = new APIGatewayV1Wrapper(globals_1.default.credentials);
this.apiGatewayV2Wrapper = new APIGatewayV2Wrapper(globals_1.default.credentials);
this.cloudFormationWrapper = new CloudFormationWrapper(globals_1.default.credentials);
this.s3Wrapper = new S3Wrapper(globals_1.default.credentials);
});
}
getApiGateway(domain) {
// 1. https://stackoverflow.com/questions/72339224/aws-v1-vs-v2-api-for-listing-apis-on-aws-api-gateway-return-different-data-for-t
// 2. https://aws.amazon.com/blogs/compute/announcing-http-apis-for-amazon-api-gateway/
// There are currently two API Gateway namespaces for managing API Gateway deployments.
// The API V1 namespace represents REST APIs and API V2 represents WebSocket APIs and the new HTTP APIs.
// You can create an HTTP API by using the AWS Management Console, CLI, APIs, CloudFormation, SDKs, or the Serverless Application Model (SAM).
if (domain.apiType !== globals_1.default.apiTypes.rest) {
return this.apiGatewayV2Wrapper;
}
// multi-level base path mapping is supported by Gateway V2
// https://github.com/amplify-education/serverless-domain-manager/issues/558
// https://aws.amazon.com/blogs/compute/using-multiple-segments-in-amazon-api-gateway-base-path-mapping/
if (domain.basePath.includes("/")) {
return this.apiGatewayV2Wrapper;
}
return this.apiGatewayV1Wrapper;
}
/**
* Lifecycle function to create a domain
* Wraps creating a domain and resource record set
*/
createDomains() {
return __awaiter(this, void 0, void 0, function* () {
yield Promise.all(this.domains.map((domain) => __awaiter(this, void 0, void 0, function* () {
yield this.createDomain(domain);
})));
});
}
/**
* Lifecycle function to create a domain
* Wraps creating a domain and resource record set
*/
createDomain(domain) {
return __awaiter(this, void 0, void 0, function* () {
const creationProgress = globals_1.default.v3Utils && globals_1.default.v3Utils.progress.get(`create-${domain.givenDomainName}`);
const route53Creds = domain.route53Profile ? yield globals_1.default.getProfileCreds(domain.route53Profile) : globals_1.default.credentials;
const apiGateway = this.getApiGateway(domain);
const route53 = new Route53Wrapper(route53Creds, domain.route53Region);
const acm = new ACMWrapper(globals_1.default.credentials, domain.endpointType);
domain.domainInfo = yield apiGateway.getCustomDomain(domain);
try {
if (!domain.domainInfo) {
if (domain.tlsTruststoreUri) {
yield this.s3Wrapper.assertTlsCertObjectExists(domain);
}
if (!domain.certificateArn) {
domain.certificateArn = yield acm.getCertArn(domain);
}
domain.domainInfo = yield apiGateway.createCustomDomain(domain);
logging_1.default.logInfo(`Custom domain '${domain.givenDomainName}' was created.
New domains may take up to 40 minutes to be initialized.`);
}
else {
logging_1.default.logInfo(`Custom domain '${domain.givenDomainName}' already exists.`);
}
yield route53.changeResourceRecordSet(client_route_53_1.ChangeAction.UPSERT, domain);
}
catch (err) {
throw new Error(`Unable to create domain '${domain.givenDomainName}':\n${err.message}`);
}
finally {
if (creationProgress) {
creationProgress.remove();
}
}
});
}
/**
* Lifecycle function to delete a domain
* Wraps deleting a domain and resource record set
*/
deleteDomains() {
return __awaiter(this, void 0, void 0, function* () {
yield Promise.all(this.domains.map((domain) => __awaiter(this, void 0, void 0, function* () {
yield this.deleteDomain(domain);
})));
});
}
/**
* Wraps deleting a domain and resource record set
*/
deleteDomain(domain) {
return __awaiter(this, void 0, void 0, function* () {
const apiGateway = this.getApiGateway(domain);
const route53Creds = domain.route53Profile ? yield globals_1.default.getProfileCreds(domain.route53Profile) : null;
const route53 = new Route53Wrapper(route53Creds, domain.route53Region);
domain.domainInfo = yield apiGateway.getCustomDomain(domain);
try {
if (domain.domainInfo) {
yield apiGateway.deleteCustomDomain(domain);
yield route53.changeResourceRecordSet(client_route_53_1.ChangeAction.DELETE, domain);
domain.domainInfo = null;
logging_1.default.logInfo(`Custom domain ${domain.givenDomainName} was deleted.`);
}
else {
logging_1.default.logInfo(`Custom domain ${domain.givenDomainName} does not exist.`);
}
}
catch (err) {
throw new Error(`Unable to delete domain '${domain.givenDomainName}':\n${err.message}`);
}
});
}
/**
* Lifecycle function to createDomain before deploy and add domain info to the CloudFormation stack's Outputs
*/
createOrGetDomainForCfOutputs() {
return __awaiter(this, void 0, void 0, function* () {
yield Promise.all(this.domains.map((domain) => __awaiter(this, void 0, void 0, function* () {
if (domain.autoDomain) {
logging_1.default.logInfo("Creating domain name before deploy.");
yield this.createDomain(domain);
}
const apiGateway = this.getApiGateway(domain);
domain.domainInfo = yield apiGateway.getCustomDomain(domain);
if (domain.autoDomain) {
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++) {
logging_1.default.logInfo(`
Poll #${i + 1}: polling every ${pollInterval} seconds
for domain to exist or until ${maxWaitFor} seconds
have elapsed before starting deployment
`);
yield (0, utils_1.sleep)(pollInterval);
domain.domainInfo = yield apiGateway.getCustomDomain(domain);
}
}
this.addOutputs(domain);
})));
});
}
/**
* Lifecycle function to create basepath mapping
* Wraps creation of basepath mapping and adds domain name info as output to cloudformation stack
*/
setupBasePathMappings() {
return __awaiter(this, void 0, void 0, function* () {
yield Promise.all(this.domains.map((domain) => __awaiter(this, void 0, void 0, function* () {
domain.apiId = yield this.cloudFormationWrapper.findApiId(domain.apiType);
const apiGateway = this.getApiGateway(domain);
const mappings = yield apiGateway.getBasePathMappings(domain);
const filteredMappings = mappings.filter((mapping) => {
if (domain.allowPathMatching) {
return mapping.basePath === domain.basePath;
}
return mapping.apiId === domain.apiId;
});
domain.apiMapping = filteredMappings ? filteredMappings[0] : null;
domain.domainInfo = yield apiGateway.getCustomDomain(domain, false);
if (!domain.apiMapping) {
yield apiGateway.createBasePathMapping(domain);
}
else {
yield apiGateway.updateBasePathMapping(domain);
}
}))).finally(() => {
logging_1.default.printDomainSummary(this.domains);
});
});
}
/**
* Lifecycle function to delete basepath mapping
* Wraps deletion of basepath mapping
*/
removeBasePathMappings() {
return __awaiter(this, void 0, void 0, function* () {
yield Promise.all(this.domains.map((domain) => __awaiter(this, void 0, void 0, function* () {
let externalBasePathExists = false;
try {
domain.apiId = yield this.cloudFormationWrapper.findApiId(domain.apiType);
// Unable to find the corresponding API, manual clean up will be required
if (!domain.apiId) {
logging_1.default.logInfo(`Unable to find corresponding API for '${domain.givenDomainName}',
API Mappings may need to be manually removed.`);
}
else {
const apiGateway = this.getApiGateway(domain);
const mappings = yield apiGateway.getBasePathMappings(domain);
const filteredMappings = mappings.filter((mapping) => {
if (domain.allowPathMatching) {
return mapping.basePath === domain.basePath;
}
return mapping.apiId === domain.apiId && mapping.stage === domain.stage;
});
if (domain.preserveExternalPathMappings) {
externalBasePathExists = mappings.length > filteredMappings.length;
}
domain.apiMapping = filteredMappings ? filteredMappings[0] : null;
if (domain.apiMapping) {
yield apiGateway.deleteBasePathMapping(domain);
}
else {
logging_1.default.logWarning(`Api mapping was not found for '${domain.givenDomainName}'. Skipping base path deletion.`);
}
}
}
catch (err) {
if (err.message.indexOf("Failed to find CloudFormation") > -1) {
logging_1.default.logWarning(`Unable to find Cloudformation Stack for ${domain.givenDomainName},
API Mappings may need to be manually removed.`);
}
else {
logging_1.default.logWarning(`Unable to remove base path mappings for '${domain.givenDomainName}':\n${err.message}`);
}
if (domain.preserveExternalPathMappings) {
externalBasePathExists = true;
}
}
if (domain.autoDomain === true && !externalBasePathExists) {
logging_1.default.logInfo("Deleting domain name after removing base path mapping.");
yield this.deleteDomain(domain);
}
})));
});
}
/**
* Lifecycle function to print domain summary
* Wraps printing of all domain manager related info
*/
domainSummaries() {
return __awaiter(this, void 0, void 0, function* () {
yield Promise.all(this.domains.map((domain) => __awaiter(this, void 0, void 0, function* () {
const apiGateway = this.getApiGateway(domain);
domain.domainInfo = yield apiGateway.getCustomDomain(domain);
}))).finally(() => {
logging_1.default.printDomainSummary(this.domains);
});
});
}
/**
* Adds the domain name and distribution domain name to the CloudFormation outputs
*/
addOutputs(domain) {
const service = this.serverless.service;
if (!service.provider.compiledCloudFormationTemplate.Outputs) {
service.provider.compiledCloudFormationTemplate.Outputs = {};
}
// Defaults for REST and backwards compatibility
let distributionDomainNameOutputKey = "DistributionDomainName";
let domainNameOutputKey = "DomainName";
let hostedZoneIdOutputKey = "HostedZoneId";
if (domain.apiType === globals_1.default.apiTypes.http) {
distributionDomainNameOutputKey += "Http";
domainNameOutputKey += "Http";
hostedZoneIdOutputKey += "Http";
}
else if (domain.apiType === globals_1.default.apiTypes.websocket) {
distributionDomainNameOutputKey += "Websocket";
domainNameOutputKey += "Websocket";
hostedZoneIdOutputKey += "Websocket";
}
// for the CloudFormation stack we should use the `base` stage not the plugin custom stage
// Remove all special characters
const safeStage = globals_1.default.getBaseStage().replace(/[^a-zA-Z\d]/g, "");
service.provider.compiledCloudFormationTemplate.Outputs[domainNameOutputKey] = {
Value: domain.givenDomainName,
Export: {
Name: `sls-${service.service}-${safeStage}-${domainNameOutputKey}`
}
};
if (domain.domainInfo) {
service.provider.compiledCloudFormationTemplate.Outputs[distributionDomainNameOutputKey] = {
Value: domain.domainInfo.domainName,
Export: {
Name: `sls-${service.service}-${safeStage}-${distributionDomainNameOutputKey}`
}
};
service.provider.compiledCloudFormationTemplate.Outputs[hostedZoneIdOutputKey] = {
Value: domain.domainInfo.hostedZoneId,
Export: {
Name: `sls-${service.service}-${safeStage}-${hostedZoneIdOutputKey}`
}
};
}
}
}
module.exports = ServerlessCustomDomain;