cf-custom-resources/lib/custom-domain-app-runner.js (304 lines of code) (raw):

// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 /* jshint node: true */ /*jshint esversion: 8 */ "use strict"; const { fromEnv, fromTemporaryCredentials } = require("@aws-sdk/credential-providers"); const { AppRunner, AssociateCustomDomainCommand, DescribeCustomDomainsCommand, DisassociateCustomDomainCommand } = require("@aws-sdk/client-apprunner"); const { Route53, ListHostedZonesByNameCommand, ChangeResourceRecordSetsCommand, waitUntilResourceRecordSetsChanged } = require("@aws-sdk/client-route-53"); const DOMAIN_STATUS_PENDING_VERIFICATION = "pending_certificate_dns_validation"; const DOMAIN_STATUS_ACTIVE = "active"; const DOMAIN_STATUS_DELETE_FAILED = "delete_failed"; const ATTEMPTS_WAIT_FOR_PENDING = 10; // Expectedly lambda time out would be triggered before 20-th attempt. This ensures that we attempts to wait for it to be disassociated as much as possible. const ATTEMPTS_WAIT_FOR_DISASSOCIATED = 20; let defaultSleep = function (ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }; let sleep = defaultSleep; let appRoute53Client, appRunnerClient, appHostedZoneID; /** * 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})`; var 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) { console.log(`Received event: ${JSON.stringify(event)}`); const props = event.ResourceProperties; const [serviceARN, appDNSRole, customDomain, appDNSName] = [props.ServiceARN, props.AppDNSRole, props.CustomDomain, props.AppDNSName, ]; const physicalResourceID = `/associate-domain-app-runner/${customDomain}`; let handler = async function () { // Configure clients. appRoute53Client = new Route53({ credentials: fromTemporaryCredentials({ params: { RoleArn: appDNSRole, }, masterCredentials: fromEnv("AWS"), }), }); appRunnerClient = new AppRunner(); appHostedZoneID = await domainHostedZoneID(appDNSName); console.log(`Received request type ${event.RequestType}`); switch (event.RequestType) { case "Create": case "Update": await addCustomDomain(serviceARN, customDomain); break; case "Delete": await removeCustomDomain(serviceARN, customDomain); await waitForCustomDomainToBeDisassociated(serviceARN, customDomain); 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 ${serviceARN}: ${err.message}`); await report(event, context, "FAILED", physicalResourceID, null, err.message); } }; 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`) ); }); }; /** * Get the hosted zone ID of the domain name from the app account. * @param {string} domainName */ async function domainHostedZoneID(domainName) { const data = await appRoute53Client.send(new ListHostedZonesByNameCommand({ DNSName: domainName, MaxItems: "1", })); if (!data.HostedZones || data.HostedZones.length === 0) { throw new Error(`couldn't find any Hosted Zone with DNS name ${domainName}`); } return data.HostedZones[0].Id.split("/").pop(); } /** * Add custom domain for service by associating and adding records for both the domain and the validation. * Errors are not handled and are directly passed to the caller. * * @param {string} serviceARN ARN of the service that the custom domain applies to. * @param {string} customDomainName the custom domain name. */ async function addCustomDomain(serviceARN, customDomainName) { let data; try { data = await appRunnerClient.send(new AssociateCustomDomainCommand({ DomainName: customDomainName, ServiceArn: serviceARN, })); } catch (err) { const isDomainAlreadyAssociated = err.message.includes(`${customDomainName} is already associated with`); if (!isDomainAlreadyAssociated) { throw err; } } if (!data) { // If domain is already associated, data would be undefined. data = await appRunnerClient.send(new DescribeCustomDomainsCommand({ ServiceArn: serviceARN, })); } return Promise.all([ updateCNAMERecordAndWait(customDomainName, data.DNSTarget, appHostedZoneID, "UPSERT"), // Upsert the record that maps `customDomainName` to the DNS of the app runner service. validateCertForDomain(serviceARN, customDomainName), ]); } /** * Get information about domain. * @param {string} serviceARN * @param {string} domainName * @returns {object} CustomDomain object that contains information such as DomainName, Status, CertificateValidationRecords, etc. * @throws error if domain is not found in service. */ async function getDomainInfo(serviceARN, domainName) { let describeCustomDomainsInput = {ServiceArn: serviceARN,}; while (true) { const resp = await appRunnerClient.send(new DescribeCustomDomainsCommand(describeCustomDomainsInput)); for (const d of resp.CustomDomains) { if (d.DomainName === domainName) { return d; } } if (!resp.NextToken) { throw new NotAssociatedError(`domain ${domainName} is not associated`); } describeCustomDomainsInput.NextToken = resp.NextToken; } } /** * Validate certificates of the custom domain for the service by upserting validation records. * * @param {string} serviceARN ARN of the service that the custom domain applies to. * @param {string} domainName the custom domain name. * @throws wrapped error. */ async function validateCertForDomain(serviceARN, domainName) { let i, lastDomainStatus; for (i = 0; i < ATTEMPTS_WAIT_FOR_PENDING; i++){ const domain = await getDomainInfo(serviceARN, domainName).catch(err => { throw new Error(`update validation records for domain ${domainName}: ` + err.message); }); lastDomainStatus = domain.Status; if (!domainValidationRecordReady(domain)) { await sleep(3000); continue; } // Upsert all records needed for certificate validation. const records = domain.CertificateValidationRecords; let promises = []; for (const record of records) { promises.push( updateCNAMERecordAndWait(record.Name, record.Value, appHostedZoneID, "UPSERT").catch(err => { throw new Error(`update validation records for domain ${domainName}: ` + err.message); }) ); } return Promise.all(promises); } if (i === ATTEMPTS_WAIT_FOR_PENDING) { throw new Error(`update validation records for domain ${domainName}: fail to wait for state ${DOMAIN_STATUS_PENDING_VERIFICATION}, stuck in ${lastDomainStatus}`); } } /** * There are one known scenarios where status could be ACTIVE right after it's associated: * When the domain just got deleted and added again. In this case, even though the validation records could * have been deleted, the previously successful validation results are still cached. Because of the cache, * the domain will show to be ACTIVE immediately after it's associated , although the validation records are not * there anymore. * In this case, the status won't transit to PENDING_VERIFICATION, so we need to check whether the validation * records are ready by counting if there are three of them. * * @param {string} domain * @returns {boolean} */ function domainValidationRecordReady(domain) { if (domain.Status === DOMAIN_STATUS_PENDING_VERIFICATION) { return true; } if (domain.Status === DOMAIN_STATUS_ACTIVE && domain.CertificateValidationRecords && domain.CertificateValidationRecords.length === 3) { return true; } return false; } /** * Remove custom domain from service by disassociating and removing the records for both the domain and the validation. * If the custom domain is not found in the service, the function returns without error. * Errors are not handled and are directly passed to the caller. * * @param {string} serviceARN ARN of the service that the custom domain applies to. * @param {string} customDomainName the custom domain name. */ async function removeCustomDomain(serviceARN, customDomainName) { let data; try { data = await appRunnerClient.send(new DisassociateCustomDomainCommand({ DomainName: customDomainName, ServiceArn: serviceARN, })); } catch (err) { if (err.message.includes(`No custom domain ${customDomainName} found for the provided service`)) { return; } throw err; } return Promise.all([ updateCNAMERecordAndWait(customDomainName, data.DNSTarget, appHostedZoneID, "DELETE"), // Delete the record that maps `customDomainName` to the DNS of the app runner service. removeValidationRecords(data.CustomDomain), ]); } /** * Remove validation records for a custom domain. * * @param {object} domain information containing DomainName, Status, CertificateValidationRecords, etc. * @throws wrapped error. */ async function removeValidationRecords(domain) { const records = domain.CertificateValidationRecords; let promises = []; for (const record of records) { promises.push( updateCNAMERecordAndWait(record.Name, record.Value, appHostedZoneID, "DELETE").catch(err => { throw new Error(`delete validation records for domain ${domain.DomainName}: ` + err.message); }) ); } return Promise.all(promises); } /** * Wait for the custom domain to be disassociated. * @param {string} serviceARN the service to which the domain is added. * @param {string} customDomainName the domain name. */ async function waitForCustomDomainToBeDisassociated(serviceARN, customDomainName) { let lastDomainStatus; for (let i = 0; i < ATTEMPTS_WAIT_FOR_DISASSOCIATED; i++) { let domain; try { domain = await getDomainInfo(serviceARN, customDomainName); } catch (err) { // Domain is disassociated. if (err instanceof NotAssociatedError) { return; } throw new Error(`wait for domain ${customDomainName} to be unused: ` + err.message); } lastDomainStatus = domain.Status; if (lastDomainStatus === DOMAIN_STATUS_DELETE_FAILED) { throw new Error(`fail to disassociate domain ${customDomainName}: domain status is ${DOMAIN_STATUS_DELETE_FAILED}`); } const base = Math.pow(2, i); await sleep(Math.random() * base * 50 + base * 150); } console.log(`Fail to wait for the domain status to be disassociated. The last reported status of domain ${customDomainName} is ${lastDomainStatus}`); throw new Error(`fail to wait for domain ${customDomainName} to be disassociated`); } /** * Upserts a CNAME record and wait for the change to have taken place. * * @param {string} recordName the name of the record * @param {string} recordValue the value of the record * @param {string} hostedZoneID the ID of the hosted zone into which the record needs to be upserted. * @param {string} action the action to perform; can be "CREATE", "DELETE", or "UPSERT". * @throws wrapped error. */ async function updateCNAMERecordAndWait(recordName, recordValue, hostedZoneID, action) { let params = { ChangeBatch: { Changes: [ { Action: action, ResourceRecordSet: { Name: recordName, Type: "CNAME", TTL: 60, ResourceRecords: [ { Value: recordValue, }, ], }, }, ], }, HostedZoneId: hostedZoneID, }; let data; try { data = await appRoute53Client.send(new ChangeResourceRecordSetsCommand(params)); } catch (err) { let recordSetNotFoundErrMessageRegex = /Tried to delete resource record set \[name='.*', type='CNAME'] but it was not found/; if (action === "DELETE" && err.message.search(recordSetNotFoundErrMessageRegex) !== -1) { return; // If we attempt to `DELETE` a record that doesn't exist, the job is already done, skip waiting. } throw new Error(`update record ${recordName}: ` + err.message); } await exports.waitForRecordSetChange(appRoute53Client,data.ChangeInfo.Id).catch((err) => { throw new Error(`update record ${recordName}: wait for record sets change for ${recordName}: ` + err.message); }); } const waitForRecordSetChange = async function (route53, changeId) { // wait upto 5 minutes. await waitUntilResourceRecordSetsChanged({ client: route53, maxWaitTime: 300, minDelay: 30, maxDelay: 30, },{ Id: changeId, }); }; function NotAssociatedError(message = "") { this.message = message; } NotAssociatedError.prototype = Error.prototype; exports.domainStatusPendingVerification = DOMAIN_STATUS_PENDING_VERIFICATION; exports.waitForDomainStatusPendingAttempts = ATTEMPTS_WAIT_FOR_PENDING; exports.waitForDomainToBeDisassociatedAttempts = ATTEMPTS_WAIT_FOR_DISASSOCIATED; exports.withSleep = function (s) { sleep = s; }; exports.reset = function () { sleep = defaultSleep; }; exports.withDeadlineExpired = function (d) { exports.deadlineExpired = d; }; exports.waitForRecordSetChange = function (route53, changeId) { return waitForRecordSetChange(route53, changeId); };