UNPKG

serverless-domain-manager

Version:
474 lines (473 loc) 23.5 kB
"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;