Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Add route53 integration #37

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- Add `route53` config parameter to manage domains and certifiacte automatically using route53

## [0.8.0] - 2021-1-28
Thanks @pecirep, @miguel-a-calles-mba, @superandrew213
Expand Down Expand Up @@ -98,4 +99,4 @@ Better support for generating client code on Windows
[0.5.4]: https://github.com/MadSkills-io/fullstack-serverless/compare/v0.5.3...v0.5.4
[0.5.3]: https://github.com/MadSkills-io/fullstack-serverless/compare/v0.5.2...v0.5.3
[0.5.2]: https://github.com/MadSkills-io/fullstack-serverless/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/MadSkills-io/fullstack-serverless/compare/v0.5.0...v0.5.1
[0.5.1]: https://github.com/MadSkills-io/fullstack-serverless/compare/v0.5.0...v0.5.1
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ custom:
fullstack:
domain: my-custom-domain.com
certificate: arn:aws:acm:us-east-1:... # The ARN for the SSL cert to use form AWS CertificateManager
route53: false # Use route53 to manage domains and certificate
bucketName: webapp-deploy # Unique name for the S3 bucket to host the client assets
distributionFolder: client/dist # Path to the client assets to be uploaded to S3
indexDocument: index.html # The index document to use
Expand Down Expand Up @@ -259,6 +260,24 @@ The custom domain for your fullstack serverless app.

---

**route53**

_optional_, default: `false`

```yaml
custom:
fullstack:
...
route53: true
...
```

Use this parameter if you want the plugin to manage domains via route53, including automatic SSL certificate creation and validation through ACM (this means you can omit the `certificateArn` parameter).

If one or more domains aren't managed by a Hosted Zone in your account, the required DNS entries will be written to the console.

---

**errorDocument**

_optional_, default: `error.html`
Expand Down
61 changes: 57 additions & 4 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const bucketUtils = require('./lib/bucketUtils');
const uploadDirectory = require('./lib/upload');
const validateClient = require('./lib/validate');
const invalidateCloudfrontDistribution = require('./lib/cloudFront');
const {groupDomainsByHostedZone} = require('./lib/route53');

class ServerlessFullstackPlugin {
constructor(serverless, cliOptions) {
Expand Down Expand Up @@ -256,8 +257,9 @@ class ServerlessFullstackPlugin {
filename: filename
});

this.prepareResources(resources);
return _.merge(baseResources, resources);
return this.prepareResources(resources).then(() => {
return _.merge(baseResources, resources);
});
}

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

prepareResources(resources) {
async prepareResources(resources) {
const distributionConfig = resources.Resources.ApiDistribution.Properties.DistributionConfig;

await this.prepareRoute53(resources.Resources);

this.prepareLogging(distributionConfig);
this.prepareDomain(distributionConfig);
this.preparePriceClass(distributionConfig);
Expand All @@ -353,6 +357,55 @@ class ServerlessFullstackPlugin {

}

async prepareRoute53(resources) {
if (this.options.domain) {
const certificate = this.getConfig("certificate", null);
const distributionCertificate = resources.ApiDistribution.Properties.DistributionConfig.ViewerCertificate;

if (this.getConfig("route53", false) === true) {
const filename = path.resolve(__dirname, 'lib/resources/templates.yml');
const content = fs.readFileSync(filename, 'utf-8');
const templates = yaml.safeLoad(content, {filename});

const domains = Array.isArray(this.options.domain) ? this.options.domain : [this.options.domain];
const domainsByHostedZones = await groupDomainsByHostedZone(this.serverless, domains);
const domainsWithoutHostedZone = domainsByHostedZones
.filter((hostedZone) => !hostedZone.Id)
.reduce((acc, hostedZone) => [...acc, ...hostedZone.domains], []);
const filteredDomainsByHostedZones = domainsByHostedZones
.filter(hostedZone => !!hostedZone.Id && hostedZone.domains.length);

if (domainsWithoutHostedZone?.length > 0)
this.serverless.cli.log(`No hosted zones found for ${domainsWithoutHostedZone}, records pointing to`
+` the cloudfront domain will have to be added manually.`, "Route53", {color: "orange", underline: true});

const aliasTemplate = templates.Route53AliasTemplate;
const recordSetTemplate = aliasTemplate.Properties.RecordSets.pop();

for (const hostedZone of filteredDomainsByHostedZones) {
const recordSets = hostedZone.domains.map(domain => ({...recordSetTemplate, Name: domain}));
const alias = {...aliasTemplate, Properties: {...aliasTemplate.Properties, RecordSets: recordSets, HostedZoneId: hostedZone.Id}};
resources["Route53AliasHZ" + hostedZone.Id] = alias;
}

// only create and override if not specified
if (certificate === null) {
const certTemplate = templates.CertTemplate;
certTemplate.Properties.DomainName = domains[0];
if (domains.length > 1) certTemplate.Properties.SubjectAlternativeNames = domains.slice(1);

const route53domainValidations = filteredDomainsByHostedZones.flatMap(hz => hz.domains.map(DomainName => ({DomainName, HostedZoneId: hz.Id})));
const manualValidations = domainsWithoutHostedZone.map(DomainName => ({DomainName, ValidationDomain: DomainName}));
certTemplate.Properties.DomainValidationOptions = [...route53domainValidations, ...manualValidations];

const certResourceName = "ApiDistributionCertificate";
resources[certResourceName] = certTemplate;
distributionCertificate.AcmCertificateArn = {Ref: certResourceName};
}
}
}
}

prepareLogging(distributionConfig) {
const loggingBucket = this.getConfig('logging.bucket', null);

Expand Down Expand Up @@ -428,7 +481,7 @@ class ServerlessFullstackPlugin {
if (certificate !== null) {
this.serverless.cli.log(`Configuring SSL certificate...`);
distributionConfig.ViewerCertificate.AcmCertificateArn = certificate;
} else {
} else if (!distributionConfig.ViewerCertificate.AcmCertificateArn) {
delete distributionConfig.ViewerCertificate;
}
}
Expand Down
26 changes: 26 additions & 0 deletions lib/resources/templates.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
Route53AliasTemplate:
Type: AWS::Route53::RecordSetGroup
Properties:
HostedZoneId: hostedZoneId
RecordSets:
- Name: domain.tld
Type: A
AliasTarget:
HostedZoneId: Z2FDTNDATAQYW2 # The static CloudFront Hosted Zone ID
DNSName:
Fn::GetAtt: ["ApiDistribution", "DomainName"]
EvaluateTargetHealth: false
# ...

CertTemplate:
Type: AWS::CertificateManager::Certificate
Properties:
DomainName: domain.tld
DomainValidationOptions:
# - DomainName: domain.tld
# HostedZoneId: hostedZoneId
# - DomainName: example.com
# ValidationDomain: example.com
# ...
ValidationMethod: DNS
# SubjectAlternativeNames:
18 changes: 18 additions & 0 deletions lib/route53.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// get hosted zones, group domains by HZ.Id using .reduce and extract values
const groupDomainsByHostedZone = async (serverless, domains) => {
const r53response = await serverless.getProvider('aws').request('Route53', 'listHostedZones', {});
// we only want raw Ids
const hostedZones = r53response.HostedZones.map(hz => ({...hz, Id: hz.Id.split("/").reverse()[0]}));

const hostedZoneMap = domains.reduce((accumulator, domain) => {
const hostedZone = hostedZones.find(hostedZone => `${domain}.`.includes(hostedZone.Name));
if (accumulator[hostedZone?.Id]) accumulator[hostedZone?.Id].domains.push(domain);
else accumulator[hostedZone?.Id] = {...hostedZone, domains: [domain]};
return accumulator;
}, {});
return Object.values(hostedZoneMap);
}

module.exports = {
groupDomainsByHostedZone
};