Skip to content

Commit d8f9ac5

Browse files
authored
chore(lambda): fix svc delete failure if alias enabled for multiple deployments (#2434)
Previously if multiple LB service deployments enable alias in the same app, it starts to fail from the second deployment deletion. It is because previously the cert by default we create includes ```yaml - !Sub "${AppDNSName}" - !Sub "*.${AppDNSName}" - !Sub "${AppName}.${AppDNSName}" - !Sub "*.${AppName}.${AppDNSName}" - !Sub "*.${EnvironmentName}.${AppName}.${AppDNSName}" ``` as subject alternative names for the cert, resulting multiple certs share the same DNS validation record in app hosted zone and root hosted zone. This PR fixes this behavior by making the new cert specifically for their aliases. <!-- Provide summary of changes --> <!-- Issue number, if available. E.g. "Fixes #31", "Addresses #42, 77" --> 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 3c238b5 commit d8f9ac5

File tree

3 files changed

+318
-131
lines changed

3 files changed

+318
-131
lines changed

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

Lines changed: 121 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ let waiter;
1414
let sleep = defaultSleep;
1515
let random = Math.random;
1616
let maxAttempts = 10;
17+
let domainTypes;
1718

1819
/**
1920
* Upload a CloudFormation response object to S3.
@@ -85,36 +86,34 @@ let report = function (
8586
* Lastly, the function exits until the certificate is validated.
8687
*
8788
* @param {string} requestId the CloudFormation request ID
88-
* @param {string} appName the name of the application
89-
* @param {string} envName the name of the environment
90-
* @param {string} domainName the Common Name (CN) field for the requested certificate
91-
* @param {string} subjectAlternativeNames additional FQDNs to be included in the
92-
* Subject Alternative Name extension of the requested certificate
89+
* @param {string} appName the application name
90+
* @param {string} envName the environment name
91+
* @param {string} certDomain the domain of the certificate
92+
* @param {string} aliases the custom domain aliases
9393
* @param {string} envHostedZoneId the environment Route53 Hosted Zone ID
9494
* @param {string} rootDnsRole the IAM role ARN that can manage domainName
95-
* @param {string} isAliasEnabled whether alias is enabled
9695
* @returns {string} Validated certificate ARN
9796
*/
9897
const requestCertificate = async function (
9998
requestId,
10099
appName,
101100
envName,
102-
domainName,
103-
subjectAlternativeNames,
101+
certDomain,
102+
aliases,
104103
envHostedZoneId,
105104
rootDnsRole,
106-
isAliasEnabled,
107105
region
108106
) {
109107
const crypto = require("crypto");
110108
const [acm, envRoute53, appRoute53] = clients(region, rootDnsRole);
111-
var sansToUse =
112-
isAliasEnabled === "false"
113-
? [`*.${envName}.${appName}.${domainName}`]
114-
: subjectAlternativeNames;
109+
// For backward compatiblity.
110+
const sansToUse = [`*.${certDomain}`];
111+
for (const alias of aliases) {
112+
sansToUse.push(alias);
113+
}
115114
const reqCertResponse = await acm
116115
.requestCertificate({
117-
DomainName: `${envName}.${appName}.${domainName}`,
116+
DomainName: certDomain,
118117
SubjectAlternativeNames: sansToUse,
119118
IdempotencyToken: crypto
120119
.createHash("sha256")
@@ -170,9 +169,6 @@ const requestCertificate = async function (
170169
await updateHostedZoneRecords(
171170
"UPSERT",
172171
options,
173-
envName,
174-
appName,
175-
domainName,
176172
envRoute53,
177173
appRoute53,
178174
envHostedZoneId
@@ -195,17 +191,15 @@ const requestCertificate = async function (
195191
const updateHostedZoneRecords = async function (
196192
action,
197193
options,
198-
envName,
199-
appName,
200-
domainName,
201194
envRoute53,
202195
appRoute53,
203196
envHostedZoneId
204197
) {
205198
const promises = [];
206199
for (const option of options) {
207-
switch (option.DomainName) {
208-
case `${envName}.${appName}.${domainName}`:
200+
const domainType = await getDomainType(option.DomainName);
201+
switch (domainType) {
202+
case domainTypes.EnvDomainZone:
209203
promises.push(
210204
validateDomain({
211205
route53: envRoute53,
@@ -216,23 +210,23 @@ const updateHostedZoneRecords = async function (
216210
})
217211
);
218212
break;
219-
case `${appName}.${domainName}`:
213+
case domainTypes.AppDomainZone:
220214
promises.push(
221215
validateDomain({
222216
route53: appRoute53,
223217
record: option.ResourceRecord,
224218
action: action,
225-
domainName: `${appName}.${domainName}`,
219+
domainName: domainType.domain,
226220
})
227221
);
228222
break;
229-
case domainName:
223+
case domainTypes.RootDomainZone:
230224
promises.push(
231225
validateDomain({
232226
route53: appRoute53,
233227
record: option.ResourceRecord,
234228
action: action,
235-
domainName: domainName,
229+
domainName: domainType.domain,
236230
})
237231
);
238232
break;
@@ -250,9 +244,7 @@ const updateHostedZoneRecords = async function (
250244
// if there is no other certificate using the record.
251245
const deleteHostedZoneRecords = async function (
252246
options,
253-
envName,
254-
appName,
255-
domainName,
247+
certDomain,
256248
envRoute53,
257249
appRoute53,
258250
acm,
@@ -265,10 +257,7 @@ const deleteHostedZoneRecords = async function (
265257
isLegacyCert = true;
266258
}
267259

268-
const certsWithEnvDomain = await numOfGeneratedCertificates(
269-
acm,
270-
`${envName}.${appName}.${domainName}`
271-
);
260+
const certsWithEnvDomain = await numOfGeneratedCertificates(acm, certDomain);
272261
const isLastOne = certsWithEnvDomain === 1;
273262

274263
const newOptions = [];
@@ -293,19 +282,28 @@ const deleteHostedZoneRecords = async function (
293282
// we'll remove validation CNAME records only for app and root hosted zone,
294283
// since the legacy cert still needs the validation record in the env hosted zone.
295284
for (const option of options) {
296-
if (option.DomainName === `${envName}.${appName}.${domainName}`) {
285+
if (option.DomainName === certDomain || option.DomainName === `*.${certDomain}`) {
297286
continue;
298287
}
299288
newOptions.push(option);
300289
}
301290
break;
302291
}
292+
// Make sure DNS validation records are unique. For example: "example.com" and "*.example.com"
293+
// might have the same DNS validation record.
294+
const filteredOption = [];
295+
var uniqueValidateRecordNames = new Set();
296+
for (const option of newOptions) {
297+
var id = `${option.ResourceRecord.Name} ${option.ResourceRecord.Value}`;
298+
if (uniqueValidateRecordNames.has(id)) {
299+
continue;
300+
}
301+
uniqueValidateRecordNames.add(id);
302+
filteredOption.push(option);
303+
}
303304
await updateHostedZoneRecords(
304305
"DELETE",
305-
newOptions,
306-
envName,
307-
appName,
308-
domainName,
306+
filteredOption,
309307
envRoute53,
310308
appRoute53,
311309
envHostedZoneId
@@ -380,12 +378,13 @@ const validateDomain = async function ({
380378
* If the certificate does not exist, the function will return normally.
381379
*
382380
* @param {string} arn The certificate ARN
381+
* @param {string} certDomain the domain of the certificate
382+
* @param {string} envHostedZoneId the environment Route53 Hosted Zone ID
383+
* @param {string} rootDnsRole the IAM role ARN that can manage domainName
383384
*/
384385
const deleteCertificate = async function (
385386
arn,
386-
appName,
387-
envName,
388-
domainName,
387+
certDomain,
389388
region,
390389
envHostedZoneId,
391390
rootDnsRole
@@ -421,7 +420,6 @@ const deleteCertificate = async function (
421420
break;
422421
}
423422
}
424-
425423
if (inUseByResources.length) {
426424
throw new Error(
427425
`Certificate still in use after checking for ${maxAttempts} attempts.`
@@ -430,9 +428,7 @@ const deleteCertificate = async function (
430428

431429
await deleteHostedZoneRecords(
432430
options,
433-
envName,
434-
appName,
435-
domainName,
431+
certDomain,
436432
envRoute53,
437433
appRoute53,
438434
acm,
@@ -496,6 +492,38 @@ const updateRecords = function (
496492
.promise();
497493
};
498494

495+
// getAllAliases gets all aliases out from a string. For example:
496+
// {"frontend": ["test.foobar.com", "foobar.com"], "api": ["api.foobar.com"]} will return
497+
// ["test.foobar.com", "foobar.com", "api.foobar.com"].
498+
const getAllAliases = function (aliases) {
499+
let obj;
500+
try {
501+
obj = JSON.parse(aliases || "{}");
502+
} catch (error) {
503+
throw new Error(`Cannot parse ${aliases} into JSON format.`);
504+
}
505+
var aliasList = [];
506+
for (var m in obj) {
507+
aliasList.push(...obj[m]);
508+
}
509+
return new Set(aliasList.filter(function (itm) {
510+
return getDomainType(itm) != domainTypes.OtherDomainZone;
511+
}));
512+
};
513+
514+
const getDomainType = function (alias) {
515+
if (domainTypes.EnvDomainZone.regex.test(alias)) {
516+
return domainTypes.EnvDomainZone;
517+
}
518+
if (domainTypes.AppDomainZone.regex.test(alias)) {
519+
return domainTypes.AppDomainZone;
520+
}
521+
if (domainTypes.RootDomainZone.regex.test(alias)) {
522+
return domainTypes.RootDomainZone;
523+
}
524+
return domainTypes.OtherDomainZone;
525+
};
526+
499527
const clients = function (region, rootDnsRole) {
500528
const acm = new aws.ACM({
501529
region,
@@ -522,20 +550,64 @@ exports.certificateRequestHandler = async function (event, context) {
522550
var physicalResourceId;
523551
var certificateArn;
524552
const props = event.ResourceProperties;
553+
const [app, env, domain] = [props.AppName, props.EnvName, props.DomainName];
554+
domainTypes = {
555+
EnvDomainZone: {
556+
regex: new RegExp(`^([^\.]+\.)?${env}.${app}.${domain}`),
557+
domain: `${env}.${app}.${domain}`,
558+
},
559+
AppDomainZone: {
560+
regex: new RegExp(`^([^\.]+\.)?${app}.${domain}`),
561+
domain: `${app}.${domain}`,
562+
},
563+
RootDomainZone: {
564+
regex: new RegExp(`^([^\.]+\.)?${domain}`),
565+
domain: `${domain}`,
566+
},
567+
OtherDomainZone: {},
568+
};
525569

526570
try {
571+
var certDomain = `${props.EnvName}.${props.AppName}.${props.DomainName}`;
572+
var aliases = await getAllAliases(props.Aliases);
527573
switch (event.RequestType) {
528574
case "Create":
575+
certificateArn = await requestCertificate(
576+
event.RequestId,
577+
props.AppName,
578+
props.EnvName,
579+
certDomain,
580+
aliases,
581+
props.EnvHostedZoneId,
582+
props.RootDNSRole,
583+
props.Region
584+
);
585+
responseData.Arn = physicalResourceId = certificateArn;
586+
break;
529587
case "Update":
588+
// Exit early if cert doesn't change.
589+
if (event.OldResourceProperties) {
590+
var prevAliases = await getAllAliases(
591+
event.OldResourceProperties.Aliases
592+
);
593+
var aliasesToDelete = [...prevAliases].filter(function (itm) {
594+
return !aliases.has(itm);
595+
});
596+
var aliasesToAdd = [...aliases].filter(function (itm) {
597+
return !prevAliases.has(itm);
598+
});
599+
if (aliasesToAdd.length + aliasesToDelete.length === 0) {
600+
break;
601+
}
602+
}
530603
certificateArn = await requestCertificate(
531604
event.RequestId,
532605
props.AppName,
533606
props.EnvName,
534-
props.DomainName,
535-
props.SubjectAlternativeNames,
607+
certDomain,
608+
aliases,
536609
props.EnvHostedZoneId,
537610
props.RootDNSRole,
538-
props.IsAliasEnabled,
539611
props.Region
540612
);
541613
responseData.Arn = physicalResourceId = certificateArn;
@@ -547,9 +619,7 @@ exports.certificateRequestHandler = async function (event, context) {
547619
if (physicalResourceId.startsWith("arn:")) {
548620
await deleteCertificate(
549621
physicalResourceId,
550-
props.AppName,
551-
props.EnvName,
552-
props.DomainName,
622+
certDomain,
553623
props.Region,
554624
props.EnvHostedZoneId,
555625
props.RootDNSRole
@@ -559,7 +629,6 @@ exports.certificateRequestHandler = async function (event, context) {
559629
default:
560630
throw new Error(`Unsupported request type ${event.RequestType}`);
561631
}
562-
563632
await report(event, context, "SUCCESS", physicalResourceId, responseData);
564633
} catch (err) {
565634
console.log(`Caught error ${err}.`);

0 commit comments

Comments
 (0)