Skip to content

Commit 02e95c0

Browse files
committed
implement multi-domain setups
1 parent 8e7cde8 commit 02e95c0

File tree

3 files changed

+191
-103
lines changed

3 files changed

+191
-103
lines changed

index.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const bucketUtils = require('./lib/bucketUtils');
1212
const uploadDirectory = require('./lib/upload');
1313
const validateClient = require('./lib/validate');
1414
const {invalidateCloudfrontDistribution} = require('./lib/cloudFront');
15-
const {addAliasRecord, setupCertificate} = require('./lib/route53');
15+
const {addCloudFrontAlias, setupCertificate} = require('./lib/route53');
1616
const {getCertificateArn} = require('./lib/acm');
1717

1818
class ServerlessFullstackPlugin {
@@ -345,11 +345,11 @@ class ServerlessFullstackPlugin {
345345

346346
if (this.options.domain) {
347347
if (this.getConfig("route53", false)) {
348-
await addAliasRecord(
348+
await addCloudFrontAlias(
349349
this.serverless,
350350
this.options.domain
351351
);
352-
// only override if not specified
352+
// only create and override if not specified
353353
if (certificate === null) {
354354
distributionCertificate.AcmCertificateArn = await setupCertificate(
355355
this.serverless,

lib/acm.js

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,47 @@
1-
const getCertificateArn = async (serverless, domainName) => {
1+
const getCertificateArn = async (serverless, domain) => {
22
const awsClient = serverless.getProvider('aws'),
33
requestParams = {
44
CertificateStatuses: ['ISSUED']
55
},
6-
listCertificatesResponse = await awsClient.request('ACM', 'listCertificates', requestParams),
7-
certificate = listCertificatesResponse.CertificateSummaryList
8-
.find(certificate => certificate.DomainName === domainName);
6+
listCertificatesResponse = await awsClient.request('ACM', 'listCertificates', requestParams);
7+
8+
// if multiple domains are provided, we have to find a cert that covers all of them
9+
if (Array.isArray(domain)) {
10+
const certificate = (
11+
await Promise.all(
12+
// filter out certs without one of the domains in their "main" DomainName
13+
// before requesting detailed certificate data
14+
listCertificatesResponse.CertificateSummaryList.filter(
15+
(certificate) => domain.includes(certificate.DomainName)
16+
).map(
17+
async (certificate) =>
18+
await awsClient.request("ACM", "describeCertificate", {
19+
CertificateArn: certificate.CertificateArn,
20+
})
21+
)
22+
)
23+
).find((certificateDesc) =>
24+
domain.every((domain) =>
25+
certificateDesc.Certificate.SubjectAlternativeNames.includes(domain)
26+
)
27+
);
928

10-
return certificate ? certificate.CertificateArn : null;
29+
return certificate ? certificate.Certificate.CertificateArn : null;
30+
} else {
31+
const certificate = listCertificatesResponse.CertificateSummaryList
32+
.find(certificate => certificate.DomainName === domain);
33+
34+
return certificate ? certificate.CertificateArn : null;
35+
}
1136
}
1237

13-
const requestCertificateWithDNS = async (serverless, domainName) => {
38+
const requestCertificateWithDNS = async (serverless, domain) => {
39+
if (!Array.isArray(domain)) domain = [domain];
1440
const awsClient = serverless.getProvider('aws'),
1541
requestCertificateParams = {
16-
DomainName: domainName,
17-
ValidationMethod: 'DNS'
42+
DomainName: domain[0],
43+
ValidationMethod: 'DNS',
44+
SubjectAlternativeNames: domain.length > 1 ? domain.slice(1) : null
1845
},
1946
requestCertificateResponse = await awsClient.request('ACM', 'requestCertificate', requestCertificateParams),
2047
describeCertificateParams = {
@@ -28,4 +55,4 @@ const requestCertificateWithDNS = async (serverless, domainName) => {
2855
module.exports = {
2956
getCertificateArn,
3057
requestCertificateWithDNS
31-
}
58+
}

lib/route53.js

Lines changed: 152 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
const { getCloudFrontDomainName } = require("./cloudFront");
22
const {getCertificateArn, requestCertificateWithDNS} = require("./acm");
33

4-
const entity = 'Fullstack'
5-
64
const getHostedZoneForDomain = async (awsClient, domainName) => {
75
const r53response = await awsClient.request('Route53', 'listHostedZones', {}),
86
hostedZone = r53response.HostedZones
97
.find(hostedZone => `${domainName}.`.includes(hostedZone.Name));
108

11-
if (!hostedZone) throw `Domain is not managed by AWS, you will have to add a record for ${domainName} manually.`;
9+
//if (!hostedZone) throw `Domain is not managed by AWS, you will have to add a record for ${domainName} manually.`;
1210

1311
return hostedZone;
1412
};
@@ -29,114 +27,177 @@ const waitForChange = async (checkChange) => {
2927
return isChangeComplete
3028
} else {
3129
await new Promise(r => setTimeout(r, 1000));
32-
return await waitForChange(checkChange, serverless);
33-
};
30+
return await waitForChange(checkChange);
31+
}
3432
};
3533

36-
const entryExists = async (awsClient, hostedZone, domainName, target) => {
34+
const filterExistingAlias = async (awsClient, hostedZone, target) => {
3735
const requestParams = {
3836
HostedZoneId: hostedZone.Id
3937
},
40-
r53response = await awsClient.request('Route53', 'listResourceRecordSets', requestParams)
41-
sets = r53response.ResourceRecordSets;
38+
r53response = await awsClient.request('Route53', 'listResourceRecordSets', requestParams),
39+
sets = r53response.ResourceRecordSets,
40+
filteredDomains = hostedZone.domains.filter(domain => !sets.find(set => set.Name === `${domain}.` && set.AliasTarget?.DNSName === `${target}.`))
4241

43-
return sets.find(set => set.Name === `${domainName}.` && set.AliasTarget?.DNSName === `${target}.`);
44-
}
42+
return {...hostedZone, domains: filteredDomains};
43+
};
4544

46-
const addAliasRecord = async (serverless, domainName) => {
47-
const awsClient = serverless.getProvider('aws')
45+
const groupDomainsByHostedZone = async (awsClient, domains) =>
46+
// Get hosted Zone for each domain, group domains by HZ.Id using .reduce and extract values
47+
Object.values(
48+
await domains.reduce(async (promisedAccumulator, domain) => {
49+
const hostedZones = await promisedAccumulator;
50+
const hostedZone = await getHostedZoneForDomain(awsClient, domain);
51+
if (hostedZones[hostedZone?.Id]) hostedZones[hostedZone?.Id].domains.push(domain);
52+
else hostedZones[hostedZone?.Id] = {...hostedZone, domains: [domain]};
53+
return hostedZones;
54+
}, {})
55+
);
56+
57+
const addCloudFrontAlias = async (serverless, domains) => {
58+
if (!Array.isArray(domains)) {
59+
domains = [domains];
60+
}
61+
62+
const awsClient = serverless.getProvider('aws'),
4863
target = await getCloudFrontDomainName(serverless),
49-
hostedZone = await getHostedZoneForDomain(awsClient, domainName);
50-
51-
if (await entryExists(awsClient, hostedZone, domainName, target)) return;
52-
53-
serverless.cli.log(`Adding ALIAS record for ${domainName} to point to ${target}...`, entity);
54-
55-
const changeRecordParams = {
56-
HostedZoneId: hostedZone.Id,
57-
ChangeBatch: {
58-
Changes: [
59-
{
60-
Action: 'UPSERT',
61-
ResourceRecordSet: {
62-
Name: domainName,
63-
Type: 'A',
64-
AliasTarget: {
65-
HostedZoneId: 'Z2FDTNDATAQYW2', // global CloudFront HostedZoneId
66-
DNSName: target,
67-
EvaluateTargetHealth: false
64+
domainsByHostedZones = await groupDomainsByHostedZone(awsClient, domains),
65+
domainsWithoutHostedZone = domainsByHostedZones
66+
.filter(hostedZone => !hostedZone.Id)
67+
.reduce((acc, hostedZone) => [...acc, ...hostedZone.domains],[]),
68+
filteredDomainsByHostedZones = (await Promise.all(domainsByHostedZones
69+
.filter(hostedZone => !!hostedZone.Id)
70+
.map(hostedZone => filterExistingAlias(awsClient, hostedZone, target))))
71+
.filter(hostedZone => hostedZone.domains.length);
72+
73+
if (domainsWithoutHostedZone?.length > 0)
74+
serverless.cli.log(`No hosted zones found for ${domainsWithoutHostedZone}, records pointing to`
75+
+` ${target} will have to be added manually.`, "Route53", {color: "orange", underline: true});
76+
77+
await Promise.all(filteredDomainsByHostedZones.map(async hostedZone => {
78+
hostedZone.domains.forEach(domain =>
79+
serverless.cli.log(`Adding ALIAS record for ${domain} to point to ${target}...`)
80+
);
81+
82+
const changeRecordParams = {
83+
HostedZoneId: hostedZone.Id,
84+
ChangeBatch: {
85+
Changes: hostedZone.domains.map(domainName => (
86+
{
87+
Action: 'UPSERT',
88+
ResourceRecordSet: {
89+
Name: domainName,
90+
Type: 'A',
91+
AliasTarget: {
92+
HostedZoneId: 'Z2FDTNDATAQYW2', // global CloudFront HostedZoneId
93+
DNSName: target,
94+
EvaluateTargetHealth: false
95+
}
6896
}
6997
}
70-
}
71-
]
72-
}
73-
},
74-
changeRecordResult = await awsClient.request('Route53', 'changeResourceRecordSets', changeRecordParams);
98+
))
99+
}
100+
},
101+
changeRecordResult = await awsClient.request('Route53', 'changeResourceRecordSets', changeRecordParams);
75102

76-
// wait for DNS entry
77-
await waitForChange(() => checkChangeStatus(awsClient, changeRecordResult.ChangeInfo));
103+
// wait for DNS entry
104+
await waitForChange(() => checkChangeStatus(awsClient, changeRecordResult.ChangeInfo));
78105

79-
serverless.cli.log(`ALIAS ${domainName} -> ${target} successfully added.`, entity);
106+
serverless.cli.log(`ALIAS ${hostedZone.domains} -> ${target} successfully added.`);
80107

81-
// waitFor can't be called using Provider.request yet
82-
/*
83-
waitForRecordParams = {
84-
Id: changeRecordResult.ChangeInfo.Id
85-
},
86-
87-
{err, waitForRecordResult} = await awsClient.request('Route53', 'waitFor', 'resourceRecordSetsChanged', waitForRecordParams)
108+
// waitFor can't be called using Provider.request yet
109+
/*
110+
waitForRecordParams = {
111+
Id: changeRecordResult.ChangeInfo.Id
112+
},
88113
89-
serverless.cli.log(err)
90-
serverless.cli.log(waitForRecordResult)
91-
*/
114+
{err, waitForRecordResult} = await awsClient.request('Route53', 'waitFor', 'resourceRecordSetsChanged', waitForRecordParams)
115+
*/
116+
}));
92117
};
93118

94-
const setupCertificate = async (serverless, domainName) => {
95-
const existingCertificateArn = await getCertificateArn(serverless, domainName);
96-
if (existingCertificateArn) return existingCertificateArn;
97-
98-
serverless.cli.log(`Requesting certificate for ${domainName}...`, entity);
99-
100-
const awsClient = serverless.getProvider('aws')
101-
hostedZone = await getHostedZoneForDomain(awsClient, domainName),
102-
getCertificateRecord = async (serverless, domainName) => {
103-
const certificaterequest = await requestCertificateWithDNS(serverless, domainName);
104-
return certificaterequest.DomainValidationOptions[0].ResourceRecord
119+
const groupResourceRecordsByHostedZone = async (awsClient, resourceRecords) =>
120+
// Get hosted Zone for each resourcerecord, group resourcerecords by HZ.Id using .reduce and extract values
121+
Object.values(
122+
await resourceRecords.reduce(async (promisedAccumulator, resourceRecord) => {
123+
const hostedZones = await promisedAccumulator;
124+
const hostedZone = await getHostedZoneForDomain(awsClient, resourceRecord.Name);
125+
if (hostedZones[hostedZone?.Id]) hostedZones[hostedZone?.Id].resourceRecords.push(resourceRecord);
126+
else hostedZones[hostedZone?.Id] = {...hostedZone, resourceRecords: [resourceRecord]};
127+
return hostedZones;
128+
}, {})
129+
);
130+
131+
const setupCertificate = async (serverless, domains) => {
132+
const existingCertificateArn = await getCertificateArn(serverless, domains);
133+
if (existingCertificateArn) {
134+
return existingCertificateArn;
135+
}
136+
137+
if (!Array.isArray(domains)) {
138+
domains = [domains];
139+
}
140+
141+
serverless.cli.log(`Requesting certificate for ${domains}...`);
142+
143+
const awsClient = serverless.getProvider('aws'),
144+
getCertificateRecords = async (serverless, domains) => {
145+
const certificaterequest = await requestCertificateWithDNS(serverless, domains),
146+
resourceRecords = certificaterequest.DomainValidationOptions
147+
.filter(validationOption => validationOption.ValidationStatus !== "SUCCESS")
148+
.map(validationOption => validationOption.ResourceRecord);
149+
return resourceRecords.every(e => !!e) && resourceRecords.length === domains.length ? resourceRecords : null
105150
},
106-
// sometimes the ResourceRecord entry isn't immediately available, so we wait until it is
107-
certificateResourceRecord = await waitForChange(() => getCertificateRecord(serverless, domainName)),
108-
changeRecordParams = {
109-
HostedZoneId: hostedZone.Id,
110-
ChangeBatch: {
111-
Changes: [
112-
{
113-
Action: 'UPSERT',
114-
ResourceRecordSet: {
115-
Name: certificateResourceRecord.Name,
116-
Type: certificateResourceRecord.Type,
117-
TTL: 60,
118-
ResourceRecords: [
119-
{
120-
Value: certificateResourceRecord.Value
121-
}
122-
]
151+
// sometimes the ResourceRecords entries aren't immediately available, so we wait until they are
152+
certificateResourceRecords = await waitForChange(() => getCertificateRecords(serverless, domains)),
153+
resourceRecordsByHostedZones = await groupResourceRecordsByHostedZone(awsClient, certificateResourceRecords),
154+
resourceRecordsWithoutHostedZone = resourceRecordsByHostedZones
155+
.filter(hostedZone => !hostedZone.Id)
156+
.reduce((acc, hostedZone) => [...acc, ...hostedZone.resourceRecords],[]),
157+
filteredResourceRecordsByHostedZones = resourceRecordsByHostedZones.filter(hostedZone => !!hostedZone.Id);
158+
159+
resourceRecordsWithoutHostedZone.forEach((resourceRecord) => {
160+
serverless.cli.log(
161+
`Needs to be added manually: ${resourceRecord.Type} ${resourceRecord.Name} ${resourceRecord.Value}`,
162+
"Route53",
163+
{ color: "orange", underline: true }
164+
);
165+
});
166+
167+
await Promise.all(filteredResourceRecordsByHostedZones.map(async hostedZone => {
168+
const changeRecordParams = {
169+
HostedZoneId: hostedZone.Id,
170+
ChangeBatch: {
171+
Changes: hostedZone.resourceRecords.map(resourceRecord => (
172+
{
173+
Action: 'UPSERT',
174+
ResourceRecordSet: {
175+
Name: resourceRecord.Name,
176+
Type: resourceRecord.Type,
177+
TTL: 60,
178+
ResourceRecords: [
179+
{
180+
Value: resourceRecord.Value
181+
}
182+
]
183+
}
123184
}
124-
}
125-
]
126-
}
127-
},
128-
changeRecordResult = await awsClient.request('Route53', 'changeResourceRecordSets', changeRecordParams);
129-
130-
// wait for DNS entry
131-
await waitForChange(() => checkChangeStatus(awsClient, changeRecordResult.ChangeInfo));
132-
133-
// wait for issued certificate
134-
const certificateArn = await waitForChange(() => getCertificateArn(serverless, domainName));
135-
serverless.cli.log(`Certificate for ${domainName} successfully issued.`, entity);
185+
))
186+
}
187+
},
188+
changeRecordResult = await awsClient.request('Route53', 'changeResourceRecordSets', changeRecordParams);
189+
190+
// wait for DNS entry
191+
await waitForChange(() => checkChangeStatus(awsClient, changeRecordResult.ChangeInfo));
192+
}));
193+
194+
serverless.cli.log(`Waiting for certificate verification...`);
195+
const certificateArn = await waitForChange(() => getCertificateArn(serverless, domains));
196+
serverless.cli.log(`Certificate for ${domains} successfully issued.`);
136197
return certificateArn;
137198
};
138199

139200
module.exports = {
140-
addAliasRecord,
201+
addCloudFrontAlias,
141202
setupCertificate
142-
};
203+
};

0 commit comments

Comments
 (0)