Skip to content

Commit 43524c9

Browse files
authored
chore(custom-resource): implement lambda function that adds a custom domain to an app runner service (#2507)
Related: #2411 By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
1 parent 22f6b76 commit 43524c9

14 files changed

+2638
-704
lines changed

cf-custom-resources/lib/alb-rule-priority-generator.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ exports.nextAvailableRulePriorityHandler = async function (event, context) {
152152
"FAILED",
153153
physicalResourceId,
154154
null,
155-
`${err.message} (Log: ${defaultLogGroup || context.logGroupName}${
155+
`${err.message} (Log: ${defaultLogGroup || context.logGroupName}/${
156156
defaultLogStream || context.logStreamName
157157
})`
158158
);
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
/* jshint node: true */
5+
/*jshint esversion: 8 */
6+
7+
"use strict";
8+
9+
const AWS = require('aws-sdk');
10+
11+
const ERR_NAME_INVALID_REQUEST = "InvalidRequestException";
12+
const DOMAIN_STATUS_PENDING_VERIFICATION = "pending_certificate_dns_validation";
13+
const DOMAIN_STATUS_ACTIVE = "active";
14+
const ATTEMPTS_WAIT_FOR_PENDING = 10;
15+
const ATTEMPTS_WAIT_FOR_ACTIVE = 12;
16+
17+
let defaultSleep = function (ms) {
18+
return new Promise((resolve) => setTimeout(resolve, ms));
19+
};
20+
let sleep = defaultSleep;
21+
let appRoute53Client, appRunnerClient, appHostedZoneID;
22+
23+
/**
24+
* Upload a CloudFormation response object to S3.
25+
*
26+
* @param {object} event the Lambda event payload received by the handler function
27+
* @param {object} context the Lambda context received by the handler function
28+
* @param {string} responseStatus the response status, either 'SUCCESS' or 'FAILED'
29+
* @param {string} physicalResourceId CloudFormation physical resource ID
30+
* @param {object} [responseData] arbitrary response data object
31+
* @param {string} [reason] reason for failure, if any, to convey to the user
32+
* @returns {Promise} Promise that is resolved on success, or rejected on connection error or HTTP error response
33+
*/
34+
function report (
35+
event,
36+
context,
37+
responseStatus,
38+
physicalResourceId,
39+
responseData,
40+
reason
41+
) {
42+
return new Promise((resolve, reject) => {
43+
const https = require("https");
44+
const { URL } = require("url");
45+
46+
let reasonWithLogInfo = `${reason} (Log: ${context.logGroupName}/${context.logStreamName})`;
47+
var responseBody = JSON.stringify({
48+
Status: responseStatus,
49+
Reason: reasonWithLogInfo,
50+
PhysicalResourceId: physicalResourceId || context.logStreamName,
51+
StackId: event.StackId,
52+
RequestId: event.RequestId,
53+
LogicalResourceId: event.LogicalResourceId,
54+
Data: responseData,
55+
});
56+
57+
const parsedUrl = new URL(event.ResponseURL);
58+
const options = {
59+
hostname: parsedUrl.hostname,
60+
port: 443,
61+
path: parsedUrl.pathname + parsedUrl.search,
62+
method: "PUT",
63+
headers: {
64+
"Content-Type": "",
65+
"Content-Length": responseBody.length,
66+
},
67+
};
68+
69+
https
70+
.request(options)
71+
.on("error", reject)
72+
.on("response", (res) => {
73+
res.resume();
74+
if (res.statusCode >= 400) {
75+
reject(new Error(`Error ${res.statusCode}: ${res.statusMessage}`));
76+
} else {
77+
resolve();
78+
}
79+
})
80+
.end(responseBody, "utf8");
81+
});
82+
}
83+
84+
exports.handler = async function (event, context) {
85+
const props = event.ResourceProperties;
86+
const [serviceARN, appDNSRole, customDomain] = [props.ServiceARN, props.AppDNSRole, props.CustomDomain,];
87+
appHostedZoneID = props.HostedZoneID;
88+
const physicalResourceID = `/associate-domain-app-runner/${customDomain}`;
89+
let handler = async function () {
90+
// Configure clients.
91+
appRoute53Client = new AWS.Route53({
92+
credentials: new AWS.ChainableTemporaryCredentials({
93+
params: { RoleArn: appDNSRole, },
94+
masterCredentials: new AWS.EnvironmentCredentials("AWS"),
95+
}),
96+
});
97+
appRunnerClient = new AWS.AppRunner();
98+
99+
switch (event.RequestType) {
100+
case "Create":
101+
await addCustomDomain(serviceARN, customDomain);
102+
await waitForCustomDomainToBeActive(serviceARN, customDomain);
103+
break;
104+
case "Update":
105+
case "Delete":
106+
throw new Error("not yet implemented");
107+
default:
108+
throw new Error(`Unsupported request type ${event.RequestType}`);
109+
}
110+
};
111+
112+
try {
113+
await Promise.race([exports.deadlineExpired(), handler(),]);
114+
await report(event, context, "SUCCESS", physicalResourceID);
115+
} catch (err) {
116+
if (err.name === ERR_NAME_INVALID_REQUEST && err.message.includes(`${customDomain} is already associated with`)) {
117+
await report(event, context, "SUCCESS", physicalResourceID);
118+
return;
119+
}
120+
console.log(`Caught error for service ${serviceARN}: ${err.message}`);
121+
await report(event, context, "FAILED", physicalResourceID, null, err.message);
122+
}
123+
};
124+
125+
exports.deadlineExpired = function () {
126+
return new Promise(function (resolve, reject) {
127+
setTimeout(
128+
reject,
129+
14 * 60 * 1000 + 30 * 1000 /* 14.5 minutes*/,
130+
new Error("Lambda took longer than 14.5 minutes to update environment")
131+
);
132+
});
133+
};
134+
135+
/**
136+
* Validate certificates of the custom domain for the service by upserting validation records.
137+
* Errors are not handled and are directly passed to the caller.
138+
*
139+
* @param {string} serviceARN ARN of the service that the custom domain applies to.
140+
* @param {string} customDomainName the custom domain name.
141+
*/
142+
async function addCustomDomain(serviceARN, customDomainName) {
143+
const data = await appRunnerClient.associateCustomDomain({
144+
DomainName: customDomainName,
145+
ServiceArn: serviceARN,
146+
}).promise();
147+
148+
return Promise.all([
149+
updateCNAMERecordAndWait(customDomainName, data.DNSTarget, appHostedZoneID, "UPSERT"), // Upsert the record that maps `customDomainName` to the DNS of the app runner service.
150+
validateCertForDomain(serviceARN, customDomainName),
151+
]);
152+
}
153+
154+
/**
155+
* Wait for the custom domain to be ACTIVE.
156+
* @param {string} serviceARN the service to which the domain is added.
157+
* @param {string} customDomainName the domain name.
158+
*/
159+
async function waitForCustomDomainToBeActive(serviceARN, customDomainName) {
160+
let i;
161+
for (i = 0; i < ATTEMPTS_WAIT_FOR_ACTIVE; i++) {
162+
const data = await appRunnerClient.describeCustomDomains({
163+
ServiceArn: serviceARN,
164+
}).promise().catch(err => {
165+
throw new Error(`wait for domain ${customDomainName} to be active: ` + err.message);
166+
});
167+
168+
let domain;
169+
for (const d of data.CustomDomains) {
170+
if (d.DomainName === customDomainName) {
171+
domain = d;
172+
break;
173+
}
174+
}
175+
176+
if (!domain) {
177+
throw new Error(`wait for domain ${customDomainName} to be active: domain ${customDomainName} is not associated`);
178+
}
179+
180+
if (domain.Status !== DOMAIN_STATUS_ACTIVE) {
181+
// Exponential backoff with jitter based on 200ms base
182+
// component of backoff fixed to ensure minimum total wait time on
183+
// slow targets.
184+
const base = Math.pow(2, i);
185+
await sleep(Math.random() * base * 50 + base * 150);
186+
continue;
187+
}
188+
return;
189+
}
190+
191+
if (i === ATTEMPTS_WAIT_FOR_ACTIVE) {
192+
console.log("Fail to wait for the domain status to become ACTIVE. It usually takes a long time to validate domain and can be longer than the 15 minutes duration for which a Lambda function can run at most. Try associating the domain manually.");
193+
throw new Error(`fail to wait for domain ${customDomainName} to become ${DOMAIN_STATUS_ACTIVE}`);
194+
}
195+
}
196+
197+
/**
198+
* Get information about domain.
199+
* @param {string} serviceARN
200+
* @param {string} domainName
201+
* @returns {object} CustomDomain object that contains information such as DomainName, Status, CertificateValidationRecords, etc.
202+
* @throws error if domain is not found in service.
203+
*/
204+
async function getDomainInfo(serviceARN, domainName) {
205+
let describeCustomDomainsInput = {ServiceArn: serviceARN,};
206+
while (true) {
207+
const resp = await appRunnerClient.describeCustomDomains(describeCustomDomainsInput).promise();
208+
209+
for (const d of resp.CustomDomains) {
210+
if (d.DomainName === domainName) {
211+
return d;
212+
}
213+
}
214+
215+
if (!resp.NextToken) {
216+
throw new Error(`domain ${domainName} is not associated`);
217+
}
218+
describeCustomDomainsInput.NextToken = resp.NextToken;
219+
}
220+
}
221+
222+
/**
223+
* Validate certificates of the custom domain for the service by upserting validation records.
224+
*
225+
* @param {string} serviceARN ARN of the service that the custom domain applies to.
226+
* @param {string} domainName the custom domain name.
227+
* @throws wrapped error.
228+
*/
229+
async function validateCertForDomain(serviceARN, domainName) {
230+
let i, lastDomainStatus;
231+
for (i = 0; i < ATTEMPTS_WAIT_FOR_PENDING; i++){
232+
const domain = await getDomainInfo(serviceARN, domainName).catch(err => {
233+
throw new Error(`update validation records for domain ${domainName}: ` + err.message);
234+
});
235+
236+
lastDomainStatus = domain.Status;
237+
if (lastDomainStatus !== DOMAIN_STATUS_PENDING_VERIFICATION) {
238+
await sleep(3000);
239+
continue;
240+
}
241+
// Upsert all records needed for certificate validation.
242+
const records = domain.CertificateValidationRecords;
243+
for (const record of records) {
244+
await updateCNAMERecordAndWait(record.Name, record.Value, appHostedZoneID, "UPSERT").catch(err => {
245+
throw new Error(`update validation records for domain ${domainName}: ` + err.message);
246+
});
247+
}
248+
break;
249+
}
250+
251+
if (i === ATTEMPTS_WAIT_FOR_PENDING) {
252+
throw new Error(`update validation records for domain ${domainName}: fail to wait for state ${DOMAIN_STATUS_PENDING_VERIFICATION}, stuck in ${lastDomainStatus}`);
253+
}
254+
}
255+
256+
/**
257+
* Upserts a CNAME record and wait for the change to have taken place.
258+
*
259+
* @param {string} recordName the name of the record
260+
* @param {string} recordValue the value of the record
261+
* @param {string} hostedZoneID the ID of the hosted zone into which the record needs to be upserted.
262+
* @param {string} action the action to perform; can be "CREATE", "DELETE", or "UPSERT".
263+
* @throws wrapped error.
264+
*/
265+
async function updateCNAMERecordAndWait(recordName, recordValue, hostedZoneID, action) {
266+
let params = {
267+
ChangeBatch: {
268+
Changes: [
269+
{
270+
Action: action,
271+
ResourceRecordSet: {
272+
Name: recordName,
273+
Type: "CNAME",
274+
TTL: 60,
275+
ResourceRecords: [
276+
{
277+
Value: recordValue,
278+
},
279+
],
280+
},
281+
},
282+
],
283+
},
284+
HostedZoneId: hostedZoneID,
285+
};
286+
287+
const data = await appRoute53Client.changeResourceRecordSets(params).promise().catch((err) => {
288+
throw new Error(`update record ${recordName}: ` + err.message);
289+
});
290+
291+
await appRoute53Client.waitFor('resourceRecordSetsChanged', {
292+
// Wait up to 5 minutes
293+
$waiter: {
294+
delay: 30,
295+
maxAttempts: 10,
296+
},
297+
Id: data.ChangeInfo.Id,
298+
}).promise().catch((err) => {
299+
throw new Error(`update record ${recordName}: wait for record sets change for ${recordName}: ` + err.message);
300+
});
301+
}
302+
303+
exports.domainStatusPendingVerification = DOMAIN_STATUS_PENDING_VERIFICATION;
304+
exports.waitForDomainStatusPendingAttempts = ATTEMPTS_WAIT_FOR_PENDING;
305+
exports.waitForDomainStatusActiveAttempts = ATTEMPTS_WAIT_FOR_ACTIVE;
306+
exports.withSleep = function (s) {
307+
sleep = s;
308+
};
309+
exports.reset = function () {
310+
sleep = defaultSleep;
311+
312+
};
313+
exports.withDeadlineExpired = function (d) {
314+
exports.deadlineExpired = d;
315+
};

cf-custom-resources/lib/custom-domain.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ exports.handler = async function (event, context) {
268268
"FAILED",
269269
physicalResourceId,
270270
null,
271-
`${err.message} (Log: ${defaultLogGroup || context.logGroupName}${
271+
`${err.message} (Log: ${defaultLogGroup || context.logGroupName}/${
272272
defaultLogStream || context.logStreamName
273273
})`
274274
);

cf-custom-resources/lib/dns-cert-validator.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,7 @@ exports.certificateRequestHandler = async function (event, context) {
615615
"FAILED",
616616
physicalResourceId,
617617
null,
618-
`${err.message} (Log: ${defaultLogGroup || context.logGroupName}${
618+
`${err.message} (Log: ${defaultLogGroup || context.logGroupName}/${
619619
defaultLogStream || context.logStreamName
620620
})`
621621
);

cf-custom-resources/lib/dns-delegation.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ exports.domainDelegationHandler = async function (event, context) {
300300
"FAILED",
301301
physicalResourceId,
302302
null,
303-
`${err.message} (Log: ${defaultLogGroup || context.logGroupName}${
303+
`${err.message} (Log: ${defaultLogGroup || context.logGroupName}/${
304304
defaultLogStream || context.logStreamName
305305
})`
306306
);

cf-custom-resources/lib/env-controller.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ exports.handler = async function (event, context) {
259259
"FAILED",
260260
physicalResourceId,
261261
null,
262-
`${err.message} (Log: ${defaultLogGroup || context.logGroupName}${
262+
`${err.message} (Log: ${defaultLogGroup || context.logGroupName}/${
263263
defaultLogStream || context.logStreamName
264264
})`
265265
);

0 commit comments

Comments
 (0)