Skip to content

Commit 422a872

Browse files
committed
integrate route53 and acm:
- automatically create ALIAS record for distribution - automatically request and confirm certificate - find certificate by domain
1 parent c93035a commit 422a872

File tree

4 files changed

+223
-4
lines changed

4 files changed

+223
-4
lines changed

index.js

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ const Confirm = require('prompt-confirm');
1111
const bucketUtils = require('./lib/bucketUtils');
1212
const uploadDirectory = require('./lib/upload');
1313
const validateClient = require('./lib/validate');
14-
const invalidateCloudfrontDistribution = require('./lib/cloudFront');
14+
const {invalidateCloudfrontDistribution} = require('./lib/cloudFront');
15+
const {addAliasRecord, setupCertificate} = require('./lib/route53');
16+
const {getCertificateArn} = require('./lib/acm');
1517

1618
class ServerlessFullstackPlugin {
1719
constructor(serverless, cliOptions) {
@@ -257,7 +259,9 @@ class ServerlessFullstackPlugin {
257259
});
258260

259261
this.prepareResources(resources);
260-
return _.merge(baseResources, resources);
262+
return this.setupDomainAndCert(resources).then(() => {
263+
return _.merge(baseResources, resources);
264+
});
261265
}
262266

263267
checkForApiGataway() {
@@ -335,6 +339,35 @@ class ServerlessFullstackPlugin {
335339
this.serverless.cli.consoleLog(` ${apiDistributionDomain.OutputValue} (CNAME: ${cnameDomain})`);
336340
}
337341

342+
async setupDomainAndCert(resources) {
343+
const certificate = this.getConfig("certificate", null);
344+
const distributionCertificate = resources.Resources.ApiDistribution.Properties.DistributionConfig.ViewerCertificate;
345+
346+
if (this.options.domain) {
347+
if (this.getConfig("route53", false)) {
348+
await addAliasRecord(
349+
this.serverless,
350+
this.options.domain
351+
);
352+
// only override if not specified
353+
if (certificate === null) {
354+
distributionCertificate.AcmCertificateArn = await setupCertificate(
355+
this.serverless,
356+
this.options.domain
357+
);
358+
}
359+
} else {
360+
// only override if not specified
361+
if (certificate === null) {
362+
distributionCertificate.AcmCertificateArn = await getCertificateArn(
363+
this.serverless,
364+
this.options.domain
365+
);
366+
}
367+
}
368+
}
369+
}
370+
338371
prepareResources(resources) {
339372
const distributionConfig = resources.Resources.ApiDistribution.Properties.DistributionConfig;
340373

@@ -428,7 +461,7 @@ class ServerlessFullstackPlugin {
428461
if (certificate !== null) {
429462
this.serverless.cli.log(`Configuring SSL certificate...`);
430463
distributionConfig.ViewerCertificate.AcmCertificateArn = certificate;
431-
} else {
464+
} else if (!this.options.domain) { // if domain is set certificate will either be created or found
432465
delete distributionConfig.ViewerCertificate;
433466
}
434467
}

lib/acm.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
const getCertificateArn = async (serverless, domainName) => {
2+
const awsClient = serverless.getProvider('aws'),
3+
requestParams = {
4+
CertificateStatuses: ['ISSUED']
5+
},
6+
listCertificatesResponse = await awsClient.request('ACM', 'listCertificates', requestParams),
7+
certificate = listCertificatesResponse.CertificateSummaryList
8+
.find(certificate => certificate.DomainName === domainName);
9+
10+
return certificate ? certificate.CertificateArn : null;
11+
}
12+
13+
const requestCertificateWithDNS = async (serverless, domainName) => {
14+
const awsClient = serverless.getProvider('aws'),
15+
requestCertificateParams = {
16+
DomainName: domainName,
17+
ValidationMethod: 'DNS'
18+
},
19+
requestCertificateResponse = await awsClient.request('ACM', 'requestCertificate', requestCertificateParams),
20+
describeCertificateParams = {
21+
CertificateArn: requestCertificateResponse.CertificateArn
22+
},
23+
describeCertificateResponse = await awsClient.request('ACM', 'describeCertificate', describeCertificateParams);
24+
25+
return describeCertificateResponse.Certificate;
26+
}
27+
28+
module.exports = {
29+
getCertificateArn,
30+
requestCertificateWithDNS
31+
}

lib/cloudFront.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,17 @@ const invalidateCloudfrontDistribution = async (serverless, invalidationPaths) =
5858
serverless.cli.log('CloudFront invalidation completed.');
5959
};
6060

61-
module.exports = invalidateCloudfrontDistribution;
61+
const getCloudFrontDomainName = async (serverless) => {
62+
const awsClient = serverless.getProvider('aws'),
63+
requestParams = {
64+
Id: await getCloudFrontDistributionId(serverless)
65+
},
66+
getDistributionResponse = await awsClient.request('CloudFront', 'getDistribution', requestParams);
67+
68+
return getDistributionResponse.Distribution.DomainName;
69+
};
70+
71+
module.exports = {
72+
invalidateCloudfrontDistribution,
73+
getCloudFrontDomainName
74+
};

lib/route53.js

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
const { getCloudFrontDomainName } = require("./cloudFront");
2+
const {getCertificateArn, requestCertificateWithDNS} = require("./acm");
3+
4+
const entity = 'Fullstack'
5+
6+
const getHostedZoneForDomain = async (awsClient, domainName) => {
7+
const r53response = await awsClient.request('Route53', 'listHostedZones', {}),
8+
hostedZone = r53response.HostedZones
9+
.find(hostedZone => `${domainName}.`.includes(hostedZone.Name));
10+
11+
if (!hostedZone) throw `Domain is not managed by AWS, you will have to add a record for ${domainName} manually.`;
12+
13+
return hostedZone;
14+
};
15+
16+
const checkChangeStatus = async (awsClient, changeInfo) => {
17+
const getChangeParams = {
18+
Id: changeInfo.Id
19+
},
20+
getChangeResponse = await awsClient.request('Route53', 'getChange', getChangeParams);
21+
22+
return getChangeResponse.ChangeInfo.Status === 'INSYNC';
23+
};
24+
25+
const waitForChange = async (checkChange) => {
26+
const isChangeComplete = await checkChange;
27+
28+
if (isChangeComplete) {
29+
return isChangeComplete
30+
} else {
31+
await setTimeout(waitForChange, 1000);
32+
};
33+
};
34+
35+
const entryExists = async (awsClient, hostedZone, domainName, target) => {
36+
const requestParams = {
37+
HostedZoneId: hostedZone.Id
38+
},
39+
r53response = await awsClient.request('Route53', 'listResourceRecordSets', requestParams)
40+
sets = r53response.ResourceRecordSets;
41+
42+
return sets.find(set => set.Name === `${domainName}.` && set.AliasTarget?.DNSName === `${target}.`);
43+
}
44+
45+
const addAliasRecord = async (serverless, domainName) => {
46+
const awsClient = serverless.getProvider('aws')
47+
target = await getCloudFrontDomainName(serverless),
48+
hostedZone = await getHostedZoneForDomain(awsClient, domainName);
49+
50+
if (await entryExists(awsClient, hostedZone, domainName, target)) return;
51+
52+
serverless.cli.log(`Adding ALIAS record for ${domainName} to point to ${target}...`, entity);
53+
54+
const changeRecordParams = {
55+
HostedZoneId: hostedZone.Id,
56+
ChangeBatch: {
57+
Changes: [
58+
{
59+
Action: 'UPSERT',
60+
ResourceRecordSet: {
61+
Name: domainName,
62+
Type: 'A',
63+
AliasTarget: {
64+
HostedZoneId: 'Z2FDTNDATAQYW2', // global CloudFront HostedZoneId
65+
DNSName: target,
66+
EvaluateTargetHealth: false
67+
}
68+
}
69+
}
70+
]
71+
}
72+
},
73+
changeRecordResult = await awsClient.request('Route53', 'changeResourceRecordSets', changeRecordParams);
74+
75+
// wait for DNS entry
76+
await waitForChange(() => checkChangeStatus(awsClient, changeRecordResult.ChangeInfo));
77+
78+
serverless.cli.log(`ALIAS ${domainName} -> ${target} successfully added.`, entity);
79+
80+
// waitFor can't be called using Provider.request yet
81+
/*
82+
waitForRecordParams = {
83+
Id: changeRecordResult.ChangeInfo.Id
84+
},
85+
86+
{err, waitForRecordResult} = await awsClient.request('Route53', 'waitFor', 'resourceRecordSetsChanged', waitForRecordParams)
87+
88+
serverless.cli.log(err)
89+
serverless.cli.log(waitForRecordResult)
90+
*/
91+
};
92+
93+
const setupCertificate = async (serverless, domainName) => {
94+
const existingCertificateArn = await getCertificateArn(serverless, domainName);
95+
if (existingCertificateArn) return existingCertificateArn;
96+
97+
serverless.cli.log(`Requesting certificate for ${domainName}...`, entity);
98+
99+
const awsClient = serverless.getProvider('aws')
100+
hostedZone = await getHostedZoneForDomain(awsClient, domainName),
101+
getCertificateRecord = async (serverless, domainName) => {
102+
const certificaterequest = await requestCertificateWithDNS(serverless, domainName);
103+
return certificaterequest.DomainValidationOptions[0].ResourceRecord
104+
},
105+
// sometimes the ResourceRecord entry isn't immediately available, so we wait until it is
106+
// the anonymous function given to waitForChange has to call itself after waitForChange returns it so we get the value
107+
certificateResourceRecord = await (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+
]
123+
}
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+
await waitForChange(() => getCertificateArn(serverless, domainName));
135+
136+
serverless.cli.log(`Certificate for ${domainName} successfully issued.`, entity);
137+
};
138+
139+
module.exports = {
140+
addAliasRecord,
141+
setupCertificate
142+
};

0 commit comments

Comments
 (0)