Skip to content

Commit 8926169

Browse files
committed
integrate route53 and acm:
- automatically create ALIAS record(s) for distribution - automatically request and certificate using route53
1 parent c93035a commit 8926169

File tree

3 files changed

+101
-4
lines changed

3 files changed

+101
-4
lines changed

index.js

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +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 {groupDomainsByHostedZone} = require('./lib/route53');
1516

1617
class ServerlessFullstackPlugin {
1718
constructor(serverless, cliOptions) {
@@ -256,8 +257,9 @@ class ServerlessFullstackPlugin {
256257
filename: filename
257258
});
258259

259-
this.prepareResources(resources);
260-
return _.merge(baseResources, resources);
260+
return this.prepareResources(resources).then(() => {
261+
return _.merge(baseResources, resources);
262+
});
261263
}
262264

263265
checkForApiGataway() {
@@ -335,9 +337,11 @@ class ServerlessFullstackPlugin {
335337
this.serverless.cli.consoleLog(` ${apiDistributionDomain.OutputValue} (CNAME: ${cnameDomain})`);
336338
}
337339

338-
prepareResources(resources) {
340+
async prepareResources(resources) {
339341
const distributionConfig = resources.Resources.ApiDistribution.Properties.DistributionConfig;
340342

343+
await this.prepareRoute53(resources.Resources);
344+
341345
this.prepareLogging(distributionConfig);
342346
this.prepareDomain(distributionConfig);
343347
this.preparePriceClass(distributionConfig);
@@ -353,6 +357,56 @@ class ServerlessFullstackPlugin {
353357

354358
}
355359

360+
async prepareRoute53(resources) {
361+
if (this.options.domain) {
362+
const certificate = this.getConfig("certificate", null);
363+
const distributionCertificate = resources.ApiDistribution.Properties.DistributionConfig.ViewerCertificate;
364+
365+
if (this.getConfig("route53", false) === true) {
366+
const filename = path.resolve(__dirname, 'lib/resources/templates.yml');
367+
const content = fs.readFileSync(filename, 'utf-8');
368+
const templates = yaml.safeLoad(content, {filename});
369+
370+
const domains = Array.isArray(this.options.domain) ? this.options.domain : [this.options.domain];
371+
const domainsByHostedZones = await groupDomainsByHostedZone(this.serverless, domains);
372+
const domainsWithoutHostedZone = domainsByHostedZones
373+
.filter((hostedZone) => !hostedZone.Id)
374+
.reduce((acc, hostedZone) => [...acc, ...hostedZone.domains], []);
375+
const filteredDomainsByHostedZones = domainsByHostedZones
376+
.filter(hostedZone => !!hostedZone.Id && hostedZone.domains.length);
377+
378+
if (domainsWithoutHostedZone?.length > 0)
379+
this.serverless.cli.log(`No hosted zones found for ${domainsWithoutHostedZone}, records pointing to`
380+
+` the cloudfront domain will have to be added manually.`, "Route53", {color: "orange", underline: true});
381+
382+
const aliasTemplate = templates.Route53AliasTemplate;
383+
const recordSetTemplate = aliasTemplate.Properties.RecordSets.pop();
384+
recordSetTemplate.AliasTarget.DNSName = {"Fn::GetAtt": ["ApiDistribution", "DomainName"]};
385+
386+
for (const hostedZone of filteredDomainsByHostedZones) {
387+
const recordSets = hostedZone.domains.map(domain => ({...recordSetTemplate, Name: domain}));
388+
const alias = {...aliasTemplate, Properties: {...aliasTemplate.Properties, RecordSets: recordSets, HostedZoneId: hostedZone.Id}};
389+
resources["Route53AliasHZ" + hostedZone.Id] = alias;
390+
}
391+
392+
// only create and override if not specified
393+
if (certificate === null) {
394+
const certTemplate = templates.CertTemplate;
395+
certTemplate.Properties.DomainName = domains[0];
396+
if (domains.length > 1) certTemplate.Properties.SubjectAlternativeNames = domains.slice(1);
397+
398+
const route53domainValidations = filteredDomainsByHostedZones.flatMap(hz => hz.domains.map(DomainName => ({DomainName, HostedZoneId: hz.Id})));
399+
const manualValidations = domainsWithoutHostedZone.map(DomainName => ({DomainName, ValidationDomain: DomainName}));
400+
certTemplate.Properties.DomainValidationOptions = [...route53domainValidations, ...manualValidations];
401+
402+
const certResourceName = "ApiDistributionCertificate";
403+
resources[certResourceName] = certTemplate;
404+
distributionCertificate.AcmCertificateArn = {Ref: certResourceName};
405+
}
406+
}
407+
}
408+
}
409+
356410
prepareLogging(distributionConfig) {
357411
const loggingBucket = this.getConfig('logging.bucket', null);
358412

@@ -428,7 +482,7 @@ class ServerlessFullstackPlugin {
428482
if (certificate !== null) {
429483
this.serverless.cli.log(`Configuring SSL certificate...`);
430484
distributionConfig.ViewerCertificate.AcmCertificateArn = certificate;
431-
} else {
485+
} else if (!distributionConfig.ViewerCertificate.AcmCertificateArn) {
432486
delete distributionConfig.ViewerCertificate;
433487
}
434488
}

lib/resources/templates.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
Route53AliasTemplate:
2+
Type: AWS::Route53::RecordSetGroup
3+
Properties:
4+
HostedZoneId: hostedZoneId
5+
RecordSets:
6+
- Name: domain.tld
7+
Type: A
8+
AliasTarget:
9+
HostedZoneId: Z2FDTNDATAQYW2 # The static CloudFront Hosted Zone ID
10+
DNSName: # !GetAtt [ApiDistribution, DomainName]
11+
EvaluateTargetHealth: false
12+
# ...
13+
14+
CertTemplate:
15+
Type: AWS::CertificateManager::Certificate
16+
Properties:
17+
DomainName: domain.tld
18+
DomainValidationOptions:
19+
# - DomainName: domain.tld
20+
# HostedZoneId: hostedZoneId
21+
# - DomainName: example.com
22+
# ValidationDomain: example.com
23+
# ...
24+
ValidationMethod: DNS
25+
# SubjectAlternativeNames:

lib/route53.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// get hosted zones, group domains by HZ.Id using .reduce and extract values
2+
const groupDomainsByHostedZone = async (serverless, domains) => {
3+
const r53response = await serverless.getProvider('aws').request('Route53', 'listHostedZones', {});
4+
// we only want raw Ids
5+
const hostedZones = r53response.HostedZones.map(hz => ({...hz, Id: hz.Id.split("/").reverse()[0]}));
6+
7+
const hostedZoneMap = domains.reduce((accumulator, domain) => {
8+
const hostedZone = hostedZones.find(hostedZone => `${domain}.`.includes(hostedZone.Name));
9+
if (accumulator[hostedZone?.Id]) accumulator[hostedZone?.Id].domains.push(domain);
10+
else accumulator[hostedZone?.Id] = {...hostedZone, domains: [domain]};
11+
return accumulator;
12+
}, {});
13+
return Object.values(hostedZoneMap);
14+
}
15+
16+
module.exports = {
17+
groupDomainsByHostedZone
18+
};

0 commit comments

Comments
 (0)