cf-custom-resources/lib/wkld-custom-domain.js (393 lines of code) (raw):

// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 "use strict"; const { fromEnv, fromTemporaryCredentials } = require("@aws-sdk/credential-providers"); const { ACM } = require("@aws-sdk/client-acm"); const { ResourceGroupsTaggingAPI } = require("@aws-sdk/client-resource-groups-tagging-api"); const { Route53, ListHostedZonesByNameCommand, ChangeResourceRecordSetsCommand, ListResourceRecordSetsCommand, waitUntilResourceRecordSetsChanged } = require("@aws-sdk/client-route-53"); const ATTEMPTS_VALIDATION_OPTIONS_READY = 10; const ATTEMPTS_RECORD_SETS_CHANGE = 10; const DELAY_RECORD_SETS_CHANGE_IN_S = 30; let envHostedZoneID, appName, envName, serviceName, domainTypes, rootDNSRole, domainName; let defaultSleep = function (ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }; let sleep = defaultSleep; const appRoute53Context = () => { let client; return () => { if (!client) { client = new Route53({ credentials: fromTemporaryCredentials({ params: { RoleArn: rootDNSRole }, masterCredentials: fromEnv("AWS"), }), }); } return client; }; }; const envRoute53Context = () => { let client; return () => { if (!client) { client = new Route53(); } return client; }; }; const acmContext = () => { let client; return () => { if (!client) { client = new ACM(); } return client; }; }; const resourceGroupsTaggingAPIContext = () => { let client; return () => { if (!client) { client = new ResourceGroupsTaggingAPI(); } return client; }; }; const clients = { app: { route53: appRoute53Context(), }, root: { route53: appRoute53Context(), }, env: { route53: envRoute53Context(), }, acm: acmContext(), resourceGroupsTaggingAPI: resourceGroupsTaggingAPIContext(), }; const appHostedZoneIDContext = () => { let id; return async () => { if (!id) { id = await hostedZoneIDByName(`${appName}.${domainName}`); } return id; }; }; const rootHostedZoneIDContext = () => { let id; return async () => { if (!id) { id = await hostedZoneIDByName(`${domainName}`); } return id; }; }; let hostedZoneID = { app: appHostedZoneIDContext(), root: rootHostedZoneIDContext(), }; /** * Upload a CloudFormation response object to S3. * * @param {object} event the Lambda event payload received by the handler function * @param {object} context the Lambda context received by the handler function * @param {string} responseStatus the response status, either 'SUCCESS' or 'FAILED' * @param {string} physicalResourceId CloudFormation physical resource ID * @param {object} [responseData] arbitrary response data object * @param {string} [reason] reason for failure, if any, to convey to the user * @returns {Promise} Promise that is resolved on success, or rejected on connection error or HTTP error response */ function report(event, context, responseStatus, physicalResourceId, responseData, reason) { return new Promise((resolve, reject) => { const https = require("https"); const { URL } = require("url"); let reasonWithLogInfo = `${reason} (Log: ${context.logGroupName}/${context.logStreamName})`; let responseBody = JSON.stringify({ Status: responseStatus, Reason: reasonWithLogInfo, PhysicalResourceId: physicalResourceId, StackId: event.StackId, RequestId: event.RequestId, LogicalResourceId: event.LogicalResourceId, Data: responseData, }); const parsedUrl = new URL(event.ResponseURL); const options = { hostname: parsedUrl.hostname, port: 443, path: parsedUrl.pathname + parsedUrl.search, method: "PUT", headers: { "Content-Type": "", "Content-Length": responseBody.length, }, }; https .request(options) .on("error", reject) .on("response", (res) => { res.resume(); if (res.statusCode >= 400) { reject(new Error(`Error ${res.statusCode}: ${res.statusMessage}`)); } else { resolve(); } }) .end(responseBody, "utf8"); }); } exports.handler = async function (event, context) { // Destruct resource properties into local variables. const props = event.ResourceProperties; let { PublicAccessDNS: publicAccessDNS, PublicAccessHostedZoneID: publicAccessHostedZoneID } = props; const aliases = new Set(props.Aliases); // Initialize global variables. envHostedZoneID = props.EnvHostedZoneId; envName = props.EnvName; appName = props.AppName; serviceName = props.ServiceName; domainName = props.DomainName; rootDNSRole = props.RootDNSRole; domainTypes = { EnvDomainZone: { regex: new RegExp(`^([^\.]+\.)?${envName}.${appName}.${domainName}`), domain: `${envName}.${appName}.${domainName}`, }, AppDomainZone: { regex: new RegExp(`^([^\.]+\.)?${appName}.${domainName}`), domain: `${appName}.${domainName}`, }, RootDomainZone: { regex: new RegExp(`^([^\.]+\.)?${domainName}`), domain: `${domainName}`, }, }; // The PhysicalResourceID never changes because LogicalResourceId never changes. // Therefore, a "Replacement" should never happen. const physicalResourceID = event.LogicalResourceId; let handler = async function () { switch (event.RequestType) { case "Update": // Hosted Zone and DNS are not guaranteed to be the same, // so we want to be able to update routing in case alias is unchanged but hosted zone or DNS is not. let oldAliases = new Set(event.OldResourceProperties.Aliases); let oldHostedZoneId = event.OldResourceProperties.PublicAccessHostedZoneID; let oldDNS = event.OldResourceProperties.PublicAccessDNS; if (setEqual(oldAliases, aliases) && oldHostedZoneId === publicAccessHostedZoneID && oldDNS === publicAccessDNS) { break; } await validateAliases(aliases, publicAccessDNS, oldDNS); await activate(aliases, publicAccessDNS, publicAccessHostedZoneID); let unusedAliases = new Set([...oldAliases].filter((a) => !aliases.has(a))); await deactivate(unusedAliases, oldDNS, oldHostedZoneId); break; case "Create": await validateAliases(aliases, publicAccessDNS); await activate(aliases, publicAccessDNS, publicAccessHostedZoneID); break; case "Delete": await deactivate(aliases, publicAccessDNS, publicAccessHostedZoneID); break; default: throw new Error(`Unsupported request type ${event.RequestType}`); } }; try { await Promise.race([exports.deadlineExpired(), handler()]); await report(event, context, "SUCCESS", physicalResourceID); } catch (err) { console.log(`Caught error for service ${serviceName}: ${err.message}`); await report(event, context, "FAILED", physicalResourceID, null, err.message); } }; /** * Validate that the aliases are not in use. * * @param {Set<String>} aliases for the service. * @param {String} publicAccessDNS the DNS of the service's load balancer. * @param {String} oldPublicAccessDNS the old DNS of the service's load balancer. * @throws error if at least one of the aliases is not valid. */ async function validateAliases(aliases, publicAccessDNS, oldPublicAccessDNS) { let promises = []; for (let alias of aliases) { let { hostedZoneID, route53Client } = await domainResources(alias); const promise = route53Client .send(new ListResourceRecordSetsCommand({ HostedZoneId: hostedZoneID, MaxItems: "1", StartRecordName: alias, StartRecordType: "A", })) .then(({ ResourceRecordSets: recordSet }) => { if (!targetRecordExists(alias, recordSet)) { return; } if (recordSet[0].Type !== "A") { return; } let aliasTarget = recordSet[0].AliasTarget; if (aliasTarget && aliasTarget.DNSName.toLowerCase() === `${publicAccessDNS.toLowerCase()}.`) { return; // The record is an alias record and is in use by myself, hence valid. } if (aliasTarget && oldPublicAccessDNS && aliasTarget.DNSName.toLowerCase() === `${oldPublicAccessDNS.toLowerCase()}.`) { return; // The record was used by the old DNS, therefore is now used by the current DNS, hence valid. } if (aliasTarget) { throw new Error(`Alias ${alias} is already in use by ${aliasTarget.DNSName}. This could be another load balancer of a different service.`); } throw new Error(`Alias ${alias} is already in use`); }); promises.push(promise); } await Promise.all(promises); } /** * Add A-records for the aliases * @param {Set<String>}aliases * @param {String} publicAccessDNS * @param {String} publicAccessHostedZone * @returns {Promise<void>} */ async function activate(aliases, publicAccessDNS, publicAccessHostedZone) { let promises = []; for (let alias of aliases) { promises.push(activateAlias(alias, publicAccessDNS, publicAccessHostedZone)); } await Promise.all(promises); } /** * Add an A-record that points to the load balancer DNS as an alias target for the alias to its corresponding hosted zone. * @param {String} alias * @param {String} publicAccessDNS * @param {String} publicAccessHostedZone * @returns {Promise<void>} */ async function activateAlias(alias, publicAccessDNS, publicAccessHostedZone) { // NOTE: It has been validated that if the alias is in use, it is in use by the service itself. // Therefore, an "UPSERT" will not overwrite a record that belongs to another service. let changes = [ { Action: "UPSERT", ResourceRecordSet: { Name: alias, Type: "A", AliasTarget: { DNSName: publicAccessDNS, EvaluateTargetHealth: true, HostedZoneId: publicAccessHostedZone, }, }, }, ]; let { hostedZoneID, route53Client } = await domainResources(alias); let { ChangeInfo } = await route53Client .send(new ChangeResourceRecordSetsCommand({ ChangeBatch: { Comment: `Upsert A-record for alias ${alias}`, Changes: changes, }, HostedZoneId: hostedZoneID, })); await exports.waitForRecordChange(route53Client,ChangeInfo.Id); } /** * * @param {Set<String>} aliases * @param {String} publicAccessDNS * @param {String} publicAccessHostedZoneID * @returns {Promise<void>} */ async function deactivate(aliases, publicAccessDNS, publicAccessHostedZoneID) { let promises = []; for (let alias of aliases) { promises.push(deactivateAlias(alias, publicAccessDNS, publicAccessHostedZoneID)); } await Promise.all(promises); } /** * Remove the A-record of an alias that points to the load balancer DNS from its corresponding hosted zone. * * @param {String} alias * @param {String} publicAccessDNS * @param {String} publicAccessHostedZoneID * @returns {Promise<void>} */ async function deactivateAlias(alias, publicAccessDNS, publicAccessHostedZoneID) { // NOTE: It has been validated that if the alias is in use, it is in use by the service itself. // Therefore, an "UPSERT" will not overwrite a record that belongs to another service. let changes = [ { Action: "DELETE", ResourceRecordSet: { Name: alias, Type: "A", AliasTarget: { DNSName: publicAccessDNS, EvaluateTargetHealth: true, HostedZoneId: publicAccessHostedZoneID, }, }, }, ]; let { hostedZoneID, route53Client } = await domainResources(alias); let changeResourceRecordSetsInput = { ChangeBatch: { Comment: `Delete the A-record for ${alias}`, Changes: changes, }, HostedZoneId: hostedZoneID, }; let changeInfo; try { ({ ChangeInfo: changeInfo } = await route53Client.send(new ChangeResourceRecordSetsCommand(changeResourceRecordSetsInput))); } catch (e) { let recordSetNotFoundErrMessageRegex = new RegExp(".*Tried to delete resource record set.*but it was not found.*"); if (recordSetNotFoundErrMessageRegex.test(e.message)) { return; // If we attempt to `DELETE` a record that doesn't exist, the job is already done, skip waiting. } let recordSetNotMatchedErrMessageRegex = new RegExp(".*Tried to delete resource record set.*but the values provided do not match the current values.*"); if (recordSetNotMatchedErrMessageRegex.test(e.message)) { // NOTE: The alias target, or record value is not exactly what we provided // E.g. the alias target DNS name is another load balancer or cloudfront distribution // This service should not delete the A-record if it is not being pointed to. // However, this is an unexpected situation, we should log this information. console.log(`Received error when trying to delete A-record for ${alias}: ${e.message}. Perhaps the alias record isn't pointing to ${publicAccessDNS}.`); return; } throw new Error(`delete record ${alias}: ` + e.message); } await exports.waitForRecordChange(route53Client,changeInfo.Id); } const waitForRecordChange = async function (route53, changeId) { // wait upto 5 minutes. await waitUntilResourceRecordSetsChanged({ client: route53, maxWaitTime: ATTEMPTS_RECORD_SETS_CHANGE * DELAY_RECORD_SETS_CHANGE_IN_S, minDelay: DELAY_RECORD_SETS_CHANGE_IN_S, maxDelay: DELAY_RECORD_SETS_CHANGE_IN_S, },{ Id: changeId, }); }; /** * Validate if the exact record exits in the set of records. * @param targetDomainName The domain name that the target record should have * @param recordSet * @returns {boolean} */ function targetRecordExists(targetDomainName, recordSet) { if (!recordSet || recordSet.length === 0) { return false; } return recordSet[0].Name === `${targetDomainName}.`; } async function hostedZoneIDByName(domain) { const { HostedZones } = await clients.app .route53() .send(new ListHostedZonesByNameCommand({ DNSName: domain, MaxItems: "1", })); if (!HostedZones || HostedZones.length === 0) { throw new Error(`Couldn't find any Hosted Zone with DNS name ${domainName}.`); } return HostedZones[0].Id.split("/").pop(); } async function domainResources(alias) { if (domainTypes.EnvDomainZone.regex.test(alias)) { return { domain: domainTypes.EnvDomainZone.domain, route53Client: clients.env.route53(), hostedZoneID: envHostedZoneID, }; } if (domainTypes.AppDomainZone.regex.test(alias)) { return { domain: domainTypes.AppDomainZone.domain, route53Client: clients.app.route53(), hostedZoneID: await hostedZoneID.app(), }; } if (domainTypes.RootDomainZone.regex.test(alias)) { return { domain: domainTypes.RootDomainZone.domain, route53Client: clients.root.route53(), hostedZoneID: await hostedZoneID.root(), }; } throw new UnrecognizedDomainTypeError(`unrecognized domain type for ${alias}`); } function setEqual(setA, setB) { if (setA.size !== setB.size) { return false; } for (let elem of setA) { if (!setB.has(elem)) { return false; } } return true; } function UnrecognizedDomainTypeError(message = "") { this.message = message; } UnrecognizedDomainTypeError.prototype = Object.create(Error.prototype, { constructor: { value: Error, enumerable: false, writable: true, configurable: true, }, }); exports.deadlineExpired = function () { return new Promise(function (resolve, reject) { setTimeout(reject, 14 * 60 * 1000 + 30 * 1000 /* 14.5 minutes*/, new Error(`Lambda took longer than 14.5 minutes to update custom domain`)); }); }; exports.withSleep = function (s) { sleep = s; }; exports.reset = function () { sleep = defaultSleep; }; exports.withDeadlineExpired = function (d) { exports.deadlineExpired = d; }; exports.attemptsValidationOptionsReady = ATTEMPTS_VALIDATION_OPTIONS_READY; exports.waitForRecordChange = function (route53, changeId) { return waitForRecordChange(route53, changeId); }