Skip to content

Commit 1db6fd0

Browse files
authored
chore(custom-resources): add update and delete action for app runner alias (#2589)
This PR implements the "Delete" action for the custom resource. 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 1732fe0 commit 1db6fd0

File tree

2 files changed

+1312
-604
lines changed

2 files changed

+1312
-604
lines changed

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

Lines changed: 152 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88

99
const AWS = require('aws-sdk');
1010

11-
const ERR_NAME_INVALID_REQUEST = "InvalidRequestException";
1211
const DOMAIN_STATUS_PENDING_VERIFICATION = "pending_certificate_dns_validation";
1312
const DOMAIN_STATUS_ACTIVE = "active";
13+
const DOMAIN_STATUS_DELETE_FAILED = "delete_failed";
1414
const ATTEMPTS_WAIT_FOR_PENDING = 10;
15-
const ATTEMPTS_WAIT_FOR_ACTIVE = 12;
15+
// 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.
16+
const ATTEMPTS_WAIT_FOR_DISASSOCIATED = 20;
1617

1718
let defaultSleep = function (ms) {
1819
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -98,12 +99,13 @@ exports.handler = async function (event, context) {
9899

99100
switch (event.RequestType) {
100101
case "Create":
102+
case "Update":
101103
await addCustomDomain(serviceARN, customDomain);
102-
await waitForCustomDomainToBeActive(serviceARN, customDomain);
103104
break;
104-
case "Update":
105105
case "Delete":
106-
throw new Error("not yet implemented");
106+
await removeCustomDomain(serviceARN, customDomain);
107+
await waitForCustomDomainToBeDisassociated(serviceARN, customDomain);
108+
break;
107109
default:
108110
throw new Error(`Unsupported request type ${event.RequestType}`);
109111
}
@@ -113,10 +115,6 @@ exports.handler = async function (event, context) {
113115
await Promise.race([exports.deadlineExpired(), handler(),]);
114116
await report(event, context, "SUCCESS", physicalResourceID);
115117
} 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-
}
120118
console.log(`Caught error for service ${serviceARN}: ${err.message}`);
121119
await report(event, context, "FAILED", physicalResourceID, null, err.message);
122120
}
@@ -127,71 +125,43 @@ exports.deadlineExpired = function () {
127125
setTimeout(
128126
reject,
129127
14 * 60 * 1000 + 30 * 1000 /* 14.5 minutes*/,
130-
new Error("Lambda took longer than 14.5 minutes to update environment")
128+
new Error(`Lambda took longer than 14.5 minutes to update custom domain`)
131129
);
132130
});
133131
};
134132

135133
/**
136-
* Validate certificates of the custom domain for the service by upserting validation records.
134+
* Add custom domain for service by associating and adding records for both the domain and the validation.
137135
* Errors are not handled and are directly passed to the caller.
138136
*
139137
* @param {string} serviceARN ARN of the service that the custom domain applies to.
140138
* @param {string} customDomainName the custom domain name.
141139
*/
142140
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({
141+
let data;
142+
try {
143+
data = await appRunnerClient.associateCustomDomain({
144+
DomainName: customDomainName,
163145
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;
146+
}).promise();
147+
} catch (err) {
148+
const isDomainAlreadyAssociated = err.message.includes(`${customDomainName} is already associated with`);
149+
if (!isDomainAlreadyAssociated) {
150+
throw err;
187151
}
188-
return;
189152
}
190153

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}`);
154+
if (!data) {
155+
// If domain is already associated, data would be undefined.
156+
data = await appRunnerClient.describeCustomDomains({
157+
ServiceArn: serviceARN,
158+
}).promise();
194159
}
160+
161+
return Promise.all([
162+
updateCNAMERecordAndWait(customDomainName, data.DNSTarget, appHostedZoneID, "UPSERT"), // Upsert the record that maps `customDomainName` to the DNS of the app runner service.
163+
validateCertForDomain(serviceARN, customDomainName),
164+
]);
195165
}
196166

197167
/**
@@ -213,7 +183,7 @@ async function getDomainInfo(serviceARN, domainName) {
213183
}
214184

215185
if (!resp.NextToken) {
216-
throw new Error(`domain ${domainName} is not associated`);
186+
throw new NotAssociatedError(`domain ${domainName} is not associated`);
217187
}
218188
describeCustomDomainsInput.NextToken = resp.NextToken;
219189
}
@@ -229,30 +199,140 @@ async function getDomainInfo(serviceARN, domainName) {
229199
async function validateCertForDomain(serviceARN, domainName) {
230200
let i, lastDomainStatus;
231201
for (i = 0; i < ATTEMPTS_WAIT_FOR_PENDING; i++){
202+
232203
const domain = await getDomainInfo(serviceARN, domainName).catch(err => {
233204
throw new Error(`update validation records for domain ${domainName}: ` + err.message);
234205
});
235206

236207
lastDomainStatus = domain.Status;
237-
if (lastDomainStatus !== DOMAIN_STATUS_PENDING_VERIFICATION) {
208+
209+
if (!domainValidationRecordReady(domain)) {
238210
await sleep(3000);
239211
continue;
240212
}
213+
241214
// Upsert all records needed for certificate validation.
242215
const records = domain.CertificateValidationRecords;
216+
let promises = [];
243217
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-
});
218+
promises.push(
219+
updateCNAMERecordAndWait(record.Name, record.Value, appHostedZoneID, "UPSERT").catch(err => {
220+
throw new Error(`update validation records for domain ${domainName}: ` + err.message);
221+
})
222+
);
247223
}
248-
break;
224+
return Promise.all(promises);
249225
}
250226

251227
if (i === ATTEMPTS_WAIT_FOR_PENDING) {
252228
throw new Error(`update validation records for domain ${domainName}: fail to wait for state ${DOMAIN_STATUS_PENDING_VERIFICATION}, stuck in ${lastDomainStatus}`);
253229
}
254230
}
255231

232+
/**
233+
* There are one known scenarios where status could be ACTIVE right after it's associated:
234+
* When the domain just got deleted and added again. In this case, even though the validation records could
235+
* have been deleted, the previously successful validation results are still cached. Because of the cache,
236+
* the domain will show to be ACTIVE immediately after it's associated , although the validation records are not
237+
* there anymore.
238+
* In this case, the status won't transit to PENDING_VERIFICATION, so we need to check whether the validation
239+
* records are ready by counting if there are three of them.
240+
*
241+
* @param {string} domain
242+
* @returns {boolean}
243+
*/
244+
function domainValidationRecordReady(domain) {
245+
if (domain.Status === DOMAIN_STATUS_PENDING_VERIFICATION) {
246+
return true;
247+
}
248+
249+
if (domain.Status === DOMAIN_STATUS_ACTIVE && domain.CertificateValidationRecords && domain.CertificateValidationRecords.length === 3) {
250+
return true;
251+
}
252+
253+
return false;
254+
}
255+
256+
/**
257+
* Remove custom domain from service by disassociating and removing the records for both the domain and the validation.
258+
* If the custom domain is not found in the service, the function returns without error.
259+
* Errors are not handled and are directly passed to the caller.
260+
*
261+
* @param {string} serviceARN ARN of the service that the custom domain applies to.
262+
* @param {string} customDomainName the custom domain name.
263+
*/
264+
async function removeCustomDomain(serviceARN, customDomainName) {
265+
let data;
266+
try {
267+
data = await appRunnerClient.disassociateCustomDomain({
268+
DomainName: customDomainName,
269+
ServiceArn: serviceARN,
270+
}).promise();
271+
} catch (err) {
272+
if (err.message.includes(`No custom domain ${customDomainName} found for the provided service`)) {
273+
return;
274+
}
275+
throw err;
276+
}
277+
278+
return Promise.all([
279+
updateCNAMERecordAndWait(customDomainName, data.DNSTarget, appHostedZoneID, "DELETE"), // Delete the record that maps `customDomainName` to the DNS of the app runner service.
280+
removeValidationRecords(data.CustomDomain),
281+
]);
282+
}
283+
284+
/**
285+
* Remove validation records for a custom domain.
286+
*
287+
* @param {object} domain information containing DomainName, Status, CertificateValidationRecords, etc.
288+
* @throws wrapped error.
289+
*/
290+
async function removeValidationRecords(domain) {
291+
const records = domain.CertificateValidationRecords;
292+
let promises = [];
293+
for (const record of records) {
294+
promises.push(
295+
updateCNAMERecordAndWait(record.Name, record.Value, appHostedZoneID, "DELETE").catch(err => {
296+
throw new Error(`delete validation records for domain ${domain.DomainName}: ` + err.message);
297+
})
298+
);
299+
}
300+
return Promise.all(promises);
301+
}
302+
303+
/**
304+
* Wait for the custom domain to be disassociated.
305+
* @param {string} serviceARN the service to which the domain is added.
306+
* @param {string} customDomainName the domain name.
307+
*/
308+
async function waitForCustomDomainToBeDisassociated(serviceARN, customDomainName) {
309+
let lastDomainStatus;
310+
for (let i = 0; i < ATTEMPTS_WAIT_FOR_DISASSOCIATED; i++) {
311+
let domain;
312+
try {
313+
domain = await getDomainInfo(serviceARN, customDomainName);
314+
} catch (err) {
315+
// Domain is disassociated.
316+
if (err instanceof NotAssociatedError) {
317+
return;
318+
}
319+
throw new Error(`wait for domain ${customDomainName} to be unused: ` + err.message);
320+
}
321+
322+
lastDomainStatus = domain.Status;
323+
324+
if (lastDomainStatus === DOMAIN_STATUS_DELETE_FAILED) {
325+
throw new Error(`fail to disassociate domain ${customDomainName}: domain status is ${DOMAIN_STATUS_DELETE_FAILED}`);
326+
}
327+
328+
const base = Math.pow(2, i);
329+
await sleep(Math.random() * base * 50 + base * 150);
330+
}
331+
332+
console.log(`Fail to wait for the domain status to be disassociated. The last reported status of domain ${customDomainName} is ${lastDomainStatus}`);
333+
throw new Error(`fail to wait for domain ${customDomainName} to be disassociated`);
334+
}
335+
256336
/**
257337
* Upserts a CNAME record and wait for the change to have taken place.
258338
*
@@ -284,11 +364,11 @@ async function updateCNAMERecordAndWait(recordName, recordValue, hostedZoneID, a
284364
HostedZoneId: hostedZoneID,
285365
};
286366

287-
const data = await appRoute53Client.changeResourceRecordSets(params).promise().catch((err) => {
367+
const data = await appRoute53Client.changeResourceRecordSets(params).promise().catch((err) => {
288368
throw new Error(`update record ${recordName}: ` + err.message);
289369
});
290370

291-
await appRoute53Client.waitFor('resourceRecordSetsChanged', {
371+
await appRoute53Client.waitFor('resourceRecordSetsChanged', {
292372
// Wait up to 5 minutes
293373
$waiter: {
294374
delay: 30,
@@ -300,9 +380,14 @@ async function updateCNAMERecordAndWait(recordName, recordValue, hostedZoneID, a
300380
});
301381
}
302382

383+
function NotAssociatedError(message = "") {
384+
this.message = message;
385+
}
386+
NotAssociatedError.prototype = Error.prototype;
387+
303388
exports.domainStatusPendingVerification = DOMAIN_STATUS_PENDING_VERIFICATION;
304389
exports.waitForDomainStatusPendingAttempts = ATTEMPTS_WAIT_FOR_PENDING;
305-
exports.waitForDomainStatusActiveAttempts = ATTEMPTS_WAIT_FOR_ACTIVE;
390+
exports.waitForDomainToBeDisassociatedAttempts = ATTEMPTS_WAIT_FOR_DISASSOCIATED;
306391
exports.withSleep = function (s) {
307392
sleep = s;
308393
};

0 commit comments

Comments
 (0)