cf-custom-resources/lib/wkld-cert-validator.js (624 lines of code) (raw):
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
const { fromEnv, fromTemporaryCredentials } = require("@aws-sdk/credential-providers");
const { ACM, DescribeCertificateCommand, DeleteCertificateCommand, RequestCertificateCommand, waitUntilCertificateValidated } = require("@aws-sdk/client-acm");
const { ResourceGroupsTaggingAPI, GetResourcesCommand } = require("@aws-sdk/client-resource-groups-tagging-api");
const { Route53, ListResourceRecordSetsCommand, ChangeResourceRecordSetsCommand, ListHostedZonesByNameCommand, waitUntilResourceRecordSetsChanged } = require("@aws-sdk/client-route-53");
const CRYPTO = require("crypto");
const ATTEMPTS_VALIDATION_OPTIONS_READY = 10;
const ATTEMPTS_RECORD_SETS_CHANGE = 10;
const DELAY_RECORD_SETS_CHANGE_IN_S = 30;
const ATTEMPTS_CERTIFICATE_VALIDATED = 19;
const ATTEMPTS_CERTIFICATE_NOT_IN_USE = 12;
const DELAY_CERTIFICATE_VALIDATED_IN_S = 30;
let envHostedZoneID, appName, envName, serviceName, certificateDomain, domainTypes, rootDNSRole, domainName, isCloudFrontCert;
let defaultSleep = function (ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
};
let sleep = defaultSleep;
let random = Math.random;
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({
region: isCloudFrontCert ? "us-east-1" : undefined,
});
}
return client;
};
};
const resourceGroupsTaggingAPIContext = () => {
let client;
return () => {
if (!client) {
client = new ResourceGroupsTaggingAPI({
region: isCloudFrontCert ? "us-east-1" : undefined,
});
}
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 || context.logStreamName,
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;
// TODO: LoadBalancerDNS only exists when we use this for NLB (not for Static Site CloudFront). Eventually when we switch to use Golang,
// we'll make it dedicated to NLB or Static Site and reuse common logic.
let { LoadBalancerDNS: loadBalancerDNS } = 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;
isCloudFrontCert = props.IsCloudFrontCertificate;
certificateDomain = isCloudFrontCert ? `${serviceName}.${envName}.${appName}.${domainName}` : `${serviceName}-nlb.${envName}.${appName}.${domainName}`;
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}`,
},
};
let aliasesSorted = [...aliases].sort().join(",");
let physicalResourceID = event.PhysicalResourceId; // The certificate ARN. By default, keep old physical resource ID unchanged.
let handler = async function () {
switch (event.RequestType) {
case "Update":
let oldAliases = new Set(event.OldResourceProperties.Aliases);
let oldAliasesSorted = [...oldAliases].sort().join(",");
if (oldAliasesSorted === aliasesSorted) {
break;
}
// Fallthrough to "Create". When the aliases are different, the same actions are taken for both "Update" and "Create".
case "Create":
await validateAliases(aliases, loadBalancerDNS);
const certificateARN = await requestCertificate({
aliases: aliases,
idempotencyToken: CRYPTO.createHash("md5").update(`/${serviceName}/${aliasesSorted}`).digest("hex"),
});
physicalResourceID = certificateARN; // Update the physical resource ID if a new certificate is created.
const options = await waitForValidationOptionsToBeReady(certificateARN, aliases);
await validate(certificateARN, options);
break;
case "Delete":
if (!physicalResourceID || !physicalResourceID.startsWith("arn:")) {
// This means no certificate has been created, nor any records. Exit without doing anything.
break;
}
let unusedOptions = await unusedValidationOptions(physicalResourceID, loadBalancerDNS);
await devalidate(unusedOptions);
await deleteCertificate(physicalResourceID);
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);
}
};
/**
* Delete the certificate.
*
* @param certARN The ARN of the certificate to delete.
* @returns {Promise<void>}
*/
async function deleteCertificate(certARN) {
// NOTE: wait for certificate to be not in-used.
let attempt;
for (attempt = 0; attempt < ATTEMPTS_CERTIFICATE_NOT_IN_USE; attempt++) {
let certificate;
try {
({ Certificate: certificate } = await clients
.acm()
.send(new DescribeCertificateCommand({
CertificateArn: certARN,
})));
} catch (err) {
if (err.name === "ResourceNotFoundException") {
return;
}
throw err;
}
if (!certificate.InUseBy || certificate.InUseBy.length <= 0) {
break;
}
await sleep(30000);
}
if (attempt >= ATTEMPTS_CERTIFICATE_NOT_IN_USE) {
throw new Error(`Certificate still in use after checking for ${ATTEMPTS_CERTIFICATE_NOT_IN_USE} attempts.`);
}
await clients
.acm()
.send(new DeleteCertificateCommand({ CertificateArn: certARN }))
.catch((err) => {
if (err.name !== "ResourceNotFoundException") {
throw err;
}
});
}
/**
* Validate that the aliases are not in use.
*
* @param {Set<String>} aliases for the service.
* @param {String} loadBalancerDNS the DNS of the service's load balancer.
* @throws error if at least one of the aliases is not valid.
*/
async function validateAliases(aliases, loadBalancerDNS) {
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 loadBalancerDNS is empty, it means we are using this lambda for dedicated CloudFront for Static Site. CloudFront can't perform the same validation,
// because passing the CF domain would introduce a circular dependency (the CF can't be created/updated before cert is validated).
// And in this scenario we can just error out if an A-record exists.
if (aliasTarget && loadBalancerDNS && aliasTarget.DNSName.toLowerCase() === `${loadBalancerDNS.toLowerCase()}.`) {
return; // The record is an alias record and is in use by myself, 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);
}
/**
* Requests a public certificate from AWS Certificate Manager, using DNS validation.
*
* @param {Object} requestCertificateInput is the input to requestCertificate, containing the alias and idempotencyToken.
* @return {String} The ARN of the requested certificate.
*/
async function requestCertificate({ aliases, idempotencyToken }) {
const { CertificateArn } = await clients
.acm()
.send(new RequestCertificateCommand({
DomainName: certificateDomain,
IdempotencyToken: idempotencyToken,
SubjectAlternativeNames: aliases.size === 0 ? null : [...aliases],
Tags: [
{
Key: "copilot-application",
Value: appName,
},
{
Key: "copilot-environment",
Value: envName,
},
{
Key: "copilot-service",
Value: serviceName,
},
],
ValidationMethod: "DNS",
}));
return CertificateArn;
}
/**
* Wait until the validation options are ready
*
* @param certificateARN
* @param {Set<String>} aliases for the service.
*/
async function waitForValidationOptionsToBeReady(certificateARN, aliases) {
// If the certificate domain is one of the aliases, expect one validation option for each alias.
// Otherwise, include an extra validation option for the certificate domain itself.
let expectedCount = aliases.has(certificateDomain) ? aliases.size : aliases.size + 1;
let attempt; // TODO: This wait loops could be further abstracted.
for (attempt = 0; attempt < ATTEMPTS_VALIDATION_OPTIONS_READY; attempt++) {
let readyCount = 0;
const { Certificate } = await clients
.acm()
.send(new DescribeCertificateCommand({
CertificateArn: certificateARN,
}));
const options = Certificate.DomainValidationOptions || [];
options.forEach((option) => {
if (option.ResourceRecord && (aliases.has(option.DomainName) || option.DomainName.toLowerCase() === certificateDomain.toLowerCase())) {
readyCount++;
}
});
if (readyCount === expectedCount) {
return options;
}
// Exponential backoff with jitter based on 200ms base
// component of backoff fixed to ensure minimum total wait time on
// slow targets.
const base = Math.pow(2, attempt);
await sleep(random() * base * 50 + base * 150);
}
throw new Error(`resource validation records are not ready after ${attempt} tries`);
}
/**
* Validate the certificate.
*
* @param {String} certificateARN
* @param {Array<Object>} validationOptions
*/
async function validate(certificateARN, validationOptions) {
let promises = [];
for (let option of validationOptions) {
promises.push(validateOption(option));
}
await Promise.all(promises);
await exports.waitForCertificateValidation(certificateARN, clients.acm());
}
const waitForCertificateValidation = async function (certificateARN, acm) {
// Wait up to 9 minutes and 30 seconds
await waitUntilCertificateValidated({
client: acm,
maxWaitTime: ATTEMPTS_CERTIFICATE_VALIDATED * DELAY_CERTIFICATE_VALIDATED_IN_S,
minDelay: DELAY_CERTIFICATE_VALIDATED_IN_S, // linear backoff with jitter.
maxDelay: DELAY_CERTIFICATE_VALIDATED_IN_S,
},{
CertificateArn: certificateARN,
},
);
};
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,
});
};
/**
* Upsert the validation record for the alias.
*
* @param {Object} option
*/
async function validateOption(option) {
let changes = [
{
Action: "UPSERT",
ResourceRecordSet: {
Name: option.ResourceRecord.Name,
Type: option.ResourceRecord.Type,
TTL: 60,
ResourceRecords: [
{
Value: option.ResourceRecord.Value,
},
],
},
},
];
let { hostedZoneID, route53Client } = await domainResources(option.DomainName);
let { ChangeInfo } = await route53Client
.send(new ChangeResourceRecordSetsCommand({
ChangeBatch: {
Comment: `Validate the certificate for the alias ${option.DomainName}`,
Changes: changes,
},
HostedZoneId: hostedZoneID,
}));
await exports.waitForRecordChange(route53Client,ChangeInfo.Id);
}
/**
* Retrieve validation options that will be unused by any service.
*
* @param {String} ownedCertARN The ARN of the certificate that this custom resource manages.
* @param {String} loadBalancerDNS The DNS of the load balancer used by this service.
* @returns {Promise<Set<Object>>}
*/
async function unusedValidationOptions(ownedCertARN, loadBalancerDNS) {
// Look for validation options that will be no longer needed by this service.
const certificates = await serviceCertificates();
const { certOwned: certPendingDeletion, otherCerts: certInUse } = categorizeCertificates(certificates, ownedCertARN);
if (!certPendingDeletion) {
// Cannot find the certificate that is pending deletion; perhaps it is deleted already. Exit peacefully.
return new Set();
}
let optionsPendingDeletion = await unusedOptionsByService(certPendingDeletion, certInUse);
// For each of the options pending deletion, validate if it is in use by other services. If it is, Copilot
// will not delete it.
let promises = [];
for (const option of optionsPendingDeletion) {
const domainName = option["DomainName"];
// NOTE: The client is initialized outside of the `inUseByOtherServices` function because AWS-SDK mocks cannot
// mock its API calls if it is initialized in a callback.
let route53Client;
try {
({ route53Client } = await domainResources(domainName));
} catch (err) {
// NOTE: The UnrecognizedDomainTypeError is swallowed here because it is preferably handled inside
// `inUseByOtherServices`.
if (!err instanceof UnrecognizedDomainTypeError) {
throw err;
}
}
const promise = inUseByOtherServices(loadBalancerDNS, domainName, route53Client).then((isUsed) => {
if (isUsed) {
optionsPendingDeletion.delete(option);
}
});
promises.push(promise);
}
await Promise.all(promises);
return optionsPendingDeletion;
}
/**
* De-validate the certificate by removing its validation options.
* @param {Object} unusedOptions
* @returns {Promise<void>}
*/
async function devalidate(unusedOptions) {
let promises = [];
for (let option of unusedOptions) {
promises.push(devalidateOption(option));
}
await Promise.all(promises);
}
/**
* Delete the validation option from its corresponding hosted zone.
* @param {Object} option
* @returns {Promise<void>}
*/
async function devalidateOption(option) {
let changes = [
{
Action: "DELETE",
ResourceRecordSet: {
Name: option.ResourceRecord.Name,
Type: option.ResourceRecord.Type,
TTL: 60,
ResourceRecords: [
{
Value: option.ResourceRecord.Value,
},
],
},
},
];
let { hostedZoneID, route53Client } = await domainResources(option.DomainName);
let changeResourceRecordSetsInput = {
ChangeBatch: {
Comment: `Delete the validation record for ${option.DomainName}`,
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.
}
throw new Error(`delete record ${option.ResourceRecord.Name}: ` + e.message);
}
await exports.waitForRecordChange(route53Client,changeInfo.Id)
}
/**
* Retrieve all certificates used for the service and cache the results.
* @returns {Array<Object>} An array of descriptions for the certificates used by the service.
*/
async function serviceCertificates() {
let { ResourceTagMappingList } = await clients
.resourceGroupsTaggingAPI()
.send(new GetResourcesCommand({
TagFilters: [
{
Key: "copilot-application",
Values: [appName],
},
{
Key: "copilot-environment",
Values: [envName],
},
{
Key: "copilot-service",
Values: [serviceName],
},
],
ResourceTypeFilters: ["acm:certificate"],
}));
let certificates = [];
let promises = [];
for (const { ResourceARN: arn } of ResourceTagMappingList) {
let promise = clients
.acm()
.send(new DescribeCertificateCommand({
CertificateArn: arn,
}))
.then(({ Certificate }) => {
certificates.push(Certificate);
});
promises.push(promise);
}
await Promise.all(promises);
return certificates;
}
/**
* Retrieve the validation options that are pending deletion. An option is pending deletion if it is only used to
* validate a certificate that is pending deletion.
* @param {Object} certPendingDeletion The certificate that is pending deletion.
* @param {Array<Object>} certsInUse
* @returns {Promise<Set<Object>>} options that are pending deletion.
*/
async function unusedOptionsByService(certPendingDeletion, certsInUse) {
let optionsPendingDeletion = new Map();
for (const option of certPendingDeletion["DomainValidationOptions"]) {
if (option["ResourceRecord"]) {
optionsPendingDeletion.set(JSON.stringify(option["ResourceRecord"]), option);
}
}
for (const { DomainValidationOptions: validationOptions } of certsInUse) {
for (const option of validationOptions) {
if (option["ResourceRecord"]) {
optionsPendingDeletion.delete(JSON.stringify(option["ResourceRecord"]));
}
}
}
let options = new Set();
for (const opt of optionsPendingDeletion.values()) {
options.add(opt);
}
return options;
}
/**
* Validate if the domain name is currently in use by other services.
* @param loadBalancerDNS The DNS of the Network Load Balancer used by this service. The domain name in considered in use
* by this service, not other services, if it is an alias target pointing to this service's load balancer DNS.
* @param domainName
* @param route53Client The Route53 client to use for the domain name. This client can be a app-level client, or an
* env-level client, depending on the pattern of the domain name. It is initialized outside of the function because
* AWS-SDK mocks cannot mock the API call if the client is initialized in a callback.
* @returns {Promise<boolean>} True if it is considered in use; otherwise false.
*/
async function inUseByOtherServices(loadBalancerDNS, domainName, route53Client) {
let hostedZoneID;
try {
({ hostedZoneID } = await domainResources(domainName));
} catch (err) {
if (err instanceof UnrecognizedDomainTypeError) {
console.log(
`Found ${domainName} in subject alternative names. ` +
"It does not match any of these patterns: '.<env>.<app>.<domain>', '.<app>.<domain>' or '.<domain>'. " +
"This is unexpected. We don't error out as it may not cause any issue."
);
return true; // This option has unrecognized pattern, we can't check if it is in use, so we assume it is in use.
}
throw err;
}
const { ResourceRecordSets: recordSet } = await route53Client
.send(new ListResourceRecordSetsCommand({
HostedZoneId: hostedZoneID,
MaxItems: "1",
StartRecordName: domainName,
}));
if (!targetRecordExists(domainName, recordSet)) {
return false; // If there is no record using this domain, it is not in use.
}
// If there's no loadBalancerDNS, that means we are deleting validation records used by dedicated CloudFront. In that scenario,
// the validation record is uniquely used.
const inUseByMySelf = loadBalancerDNS
? recordSet[0].AliasTarget && recordSet[0].AliasTarget.DNSName.toLowerCase() === `${loadBalancerDNS.toLowerCase()}.`
: true;
return !inUseByMySelf;
}
/**
* Categorize a list of certificates into the certificate that corresponds to this particular custom resource, and other certificates.
* @param {Array<Object>} certificates
* @param {String} ownedCertARN The ARN of the certificate that this custom resource manages.
* @returns {Object},Array{Object}
*/
function categorizeCertificates(certificates, ownedCertARN) {
let certOwned;
let otherCerts = [];
for (const cert of certificates) {
if (cert["CertificateArn"].toLowerCase() === ownedCertARN.toLowerCase()) {
certOwned = cert;
} else {
otherCerts.push(cert);
}
}
return { certOwned, otherCerts };
}
/**
* 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.waitForCertificateValidation = function (certificateARN, acm) {
return waitForCertificateValidation(certificateARN, acm);
}
exports.waitForRecordChange = function (route53, changeId) {
return waitForRecordChange(route53, changeId);
}