Skip to content

Commit 64e9d90

Browse files
feat(aws): add custom domains for pulumi and terraform websites (#784)
Adds a cdn property to the `nitric/aws` and `nitric/awstf` stackfiles, allowing custom domain configuration for websites deployed to AWS. Custom domains require an existing DNS Hosted Zone in AWS Route 53.
1 parent ff21259 commit 64e9d90

File tree

10 files changed

+472
-257
lines changed

10 files changed

+472
-257
lines changed

cloud/aws/common/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type AwsApiConfig struct {
2828
}
2929

3030
type AwsCdnConfig struct {
31+
Domain string
3132
SkipCacheInvalidation bool `mapstructure:"skip-cache-invalidation"`
3233
}
3334

@@ -110,6 +111,7 @@ var defaultAuroraRdsClusterConfig = &AuroraRdsClusterConfig{
110111
}
111112

112113
var defaultCdnConfig = &AwsCdnConfig{
114+
Domain: "",
113115
SkipCacheInvalidation: false,
114116
}
115117

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Copyright 2021 Nitric Technologies Pty Ltd.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package resources
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"strings"
21+
22+
"github.com/aws/aws-sdk-go-v2/config"
23+
"github.com/aws/aws-sdk-go-v2/service/route53"
24+
)
25+
26+
type ZoneLookup struct {
27+
// The domain that matched the Hosted Zone lookup
28+
Domain string
29+
// The Hosted Zone ID
30+
ZoneID string
31+
// If the zone matched the domain (false) or matched the parent (true)
32+
IsParent bool
33+
}
34+
35+
func GetARecordLabel(zoneLookup *ZoneLookup) string {
36+
if !zoneLookup.IsParent {
37+
return ""
38+
}
39+
40+
return getSubdomainLabel(zoneLookup.Domain)
41+
}
42+
43+
func getSubdomainLabel(domain string) string {
44+
domainParts := strings.Split(domain, ".")
45+
if len(domainParts) > 2 {
46+
return domainParts[0]
47+
}
48+
49+
return ""
50+
}
51+
52+
func GetZoneID(domainName string) (*ZoneLookup, error) {
53+
zoneIds := GetZoneIDs([]string{domainName})
54+
if zoneIds[domainName] == nil {
55+
return nil, fmt.Errorf("zone ID not found for domain name: %s", domainName)
56+
}
57+
58+
return zoneIds[domainName], nil
59+
}
60+
61+
func GetZoneIDs(domainNames []string) map[string]*ZoneLookup {
62+
ctx := context.TODO()
63+
64+
cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("us-west-2"))
65+
if err != nil {
66+
return nil
67+
}
68+
69+
client := route53.NewFromConfig(cfg)
70+
71+
zoneMap := make(map[string]*ZoneLookup)
72+
73+
normalizedDomains := make(map[string]string)
74+
for _, d := range domainNames {
75+
d = strings.ToLower(strings.TrimSuffix(d, "."))
76+
normalizedDomains[d] = d + "."
77+
}
78+
79+
paginator := route53.NewListHostedZonesPaginator(client, &route53.ListHostedZonesInput{})
80+
hostedZones := make(map[string]string) // map of zone name -> zone ID
81+
82+
for paginator.HasMorePages() {
83+
page, err := paginator.NextPage(ctx)
84+
if err != nil {
85+
return nil
86+
}
87+
88+
for _, hz := range page.HostedZones {
89+
name := strings.ToLower(strings.TrimSuffix(*hz.Name, "."))
90+
hostedZones[name] = strings.TrimPrefix(*hz.Id, "/hostedzone/")
91+
}
92+
}
93+
94+
// Resolve each domain name
95+
for domain, normalized := range normalizedDomains {
96+
// Check full domain
97+
if id, ok := hostedZones[strings.TrimSuffix(normalized, ".")]; ok {
98+
zoneMap[domain] = &ZoneLookup{
99+
Domain: domain,
100+
ZoneID: id,
101+
IsParent: false,
102+
}
103+
continue
104+
}
105+
106+
// Try parent/root domain
107+
parts := strings.Split(domain, ".")
108+
if len(parts) > 2 {
109+
root := strings.Join(parts[1:], ".")
110+
if id, ok := hostedZones[root]; ok {
111+
zoneMap[domain] = &ZoneLookup{
112+
Domain: domain,
113+
ZoneID: id,
114+
IsParent: true,
115+
}
116+
continue
117+
}
118+
}
119+
120+
zoneMap[domain] = nil
121+
}
122+
123+
return zoneMap
124+
}

cloud/aws/deploy/api.go

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@ import (
2121
"fmt"
2222

2323
"github.com/getkin/kin-openapi/openapi3"
24+
common_domain "github.com/nitrictech/nitric/cloud/aws/common/resources"
2425
"github.com/nitrictech/nitric/cloud/common/deploy/resources"
2526
"github.com/nitrictech/nitric/cloud/common/deploy/tags"
2627
"github.com/nitrictech/nitric/core/pkg/help"
2728
deploymentspb "github.com/nitrictech/nitric/core/pkg/proto/deployments/v1"
2829
"github.com/pulumi/pulumi-aws/sdk/v5/go/aws/apigatewayv2"
2930
"github.com/pulumi/pulumi-aws/sdk/v5/go/aws/lambda"
31+
"github.com/pulumi/pulumi-aws/sdk/v5/go/aws/route53"
3032
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
3133
)
3234

@@ -235,11 +237,7 @@ func (a *NitricAwsPulumiProvider) Api(ctx *pulumi.Context, parent pulumi.Resourc
235237
if additionalApiConfig != nil {
236238
// For each specified domain name
237239
for _, domainName := range a.AwsConfig.Apis[name].Domains {
238-
_, err := newDomainName(ctx, name, domainNameArgs{
239-
domainName: domainName,
240-
api: a.Apis[name],
241-
stage: apiStage,
242-
})
240+
err = a.createApiDomainName(ctx, name, domainName, apiStage, a.Apis[name])
243241
if err != nil {
244242
return err
245243
}
@@ -250,3 +248,56 @@ func (a *NitricAwsPulumiProvider) Api(ctx *pulumi.Context, parent pulumi.Resourc
250248

251249
return nil
252250
}
251+
252+
func (a *NitricAwsPulumiProvider) createApiDomainName(ctx *pulumi.Context, name string, domainName string, stage *apigatewayv2.Stage, api *apigatewayv2.Api) error {
253+
domain, err := a.newPulumiDomainName(ctx, domainName)
254+
if err != nil {
255+
return err
256+
}
257+
258+
// Create a domain name if one has been requested
259+
apiDomainName, err := apigatewayv2.NewDomainName(ctx, fmt.Sprintf("%s-%s", name, domainName), &apigatewayv2.DomainNameArgs{
260+
DomainName: pulumi.String(domainName),
261+
DomainNameConfiguration: &apigatewayv2.DomainNameDomainNameConfigurationArgs{
262+
EndpointType: pulumi.String("REGIONAL"),
263+
SecurityPolicy: pulumi.String("TLS_1_2"),
264+
CertificateArn: domain.CertificateValidation.CertificateArn,
265+
},
266+
})
267+
if err != nil {
268+
return err
269+
}
270+
271+
// Create an API mapping for the new domain name
272+
_, err = apigatewayv2.NewApiMapping(ctx, fmt.Sprintf("%s-%s", name, domainName), &apigatewayv2.ApiMappingArgs{
273+
ApiId: api.ID(),
274+
DomainName: apiDomainName.DomainName,
275+
Stage: stage.Name,
276+
}, pulumi.DependsOn([]pulumi.Resource{stage}))
277+
if err != nil {
278+
return err
279+
}
280+
281+
subdomainName := common_domain.GetARecordLabel(domain.ZoneLookup)
282+
283+
// Create a DNS record for the domain name that maps to the APIs
284+
// regional endpoint
285+
_, err = route53.NewRecord(ctx, fmt.Sprintf("%s-%s-dnsrecord", name, domainName), &route53.RecordArgs{
286+
ZoneId: pulumi.String(domain.ZoneLookup.ZoneID),
287+
Type: pulumi.String("A"),
288+
Name: pulumi.String(subdomainName),
289+
Aliases: &route53.RecordAliasArray{
290+
&route53.RecordAliasArgs{
291+
// The target of the A record
292+
Name: apiDomainName.DomainNameConfiguration.TargetDomainName().Elem(),
293+
ZoneId: apiDomainName.DomainNameConfiguration.HostedZoneId().Elem(),
294+
EvaluateTargetHealth: pulumi.Bool(false),
295+
},
296+
},
297+
}, pulumi.DependsOn([]pulumi.Resource{domain}))
298+
if err != nil {
299+
return err
300+
}
301+
302+
return nil
303+
}

cloud/aws/deploy/domain.go

Lines changed: 28 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -18,61 +18,53 @@ package deploy
1818

1919
import (
2020
"fmt"
21-
"strings"
2221

22+
awsprovider "github.com/pulumi/pulumi-aws/sdk/v5/go/aws"
23+
24+
"github.com/nitrictech/nitric/cloud/aws/common/resources"
2325
"github.com/pulumi/pulumi-aws/sdk/v5/go/aws/acm"
24-
"github.com/pulumi/pulumi-aws/sdk/v5/go/aws/apigatewayv2"
2526
"github.com/pulumi/pulumi-aws/sdk/v5/go/aws/route53"
2627
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
2728
)
2829

29-
type domainNameArgs struct {
30-
domainName string
31-
api *apigatewayv2.Api
32-
stage *apigatewayv2.Stage
33-
}
34-
35-
type domainName struct {
30+
type Domain struct {
3631
pulumi.ResourceState
3732

38-
Name string
33+
Name string
34+
ZoneLookup *resources.ZoneLookup
35+
CertificateValidation *acm.CertificateValidation
3936
}
4037

41-
func newDomainName(ctx *pulumi.Context, name string, args domainNameArgs) (*domainName, error) {
42-
domainParts := strings.Split(args.domainName, ".")
38+
func (a *NitricAwsPulumiProvider) newPulumiDomainName(ctx *pulumi.Context, domainName string) (*Domain, error) {
39+
var err error
40+
res := &Domain{Name: domainName}
4341

44-
res := &domainName{Name: name}
42+
res.ZoneLookup, err = resources.GetZoneID(domainName)
43+
if err != nil {
44+
return nil, err
45+
}
4546

46-
err := ctx.RegisterComponentResource("nitric:api:DomainName", fmt.Sprintf("%s-%s", name, args.domainName), res)
47+
err = ctx.RegisterComponentResource("nitric:api:DomainName", fmt.Sprintf("%s-%s", domainName, a.StackId), res)
4748
if err != nil {
4849
return nil, err
4950
}
5051

5152
defaultOptions := []pulumi.ResourceOption{pulumi.Parent(res)}
5253

53-
// Treat this domain as root by default
54-
baseName := ""
55-
// attempt to find hosted zone as the root domain name
56-
hostedZone, err := route53.LookupZone(ctx, &route53.LookupZoneArgs{
57-
// The name is the base name for the domain
58-
Name: &args.domainName,
59-
})
60-
if err != nil {
61-
// try by parent domain instead
62-
parentDomain := strings.Join(domainParts[1:], ".")
63-
hostedZone, err = route53.LookupZone(ctx, &route53.LookupZoneArgs{
64-
// The name is the base name for the domain
65-
Name: &parentDomain,
54+
// Create an AWS provider for the us-east-1 region as the acm certificates require being deployed in us-east-1 region
55+
if a.Region != "us-east-1" {
56+
useast1, err := awsprovider.NewProvider(ctx, "us-east-1", &awsprovider.ProviderArgs{
57+
Region: pulumi.String("us-east-1"),
6658
})
6759
if err != nil {
68-
return nil, fmt.Errorf("unable to find Route53 hosted zone to create records in: %w", err)
60+
return nil, err
6961
}
7062

71-
baseName = domainParts[0]
63+
defaultOptions = append(defaultOptions, pulumi.Provider(useast1))
7264
}
7365

74-
cert, err := acm.NewCertificate(ctx, fmt.Sprintf("%s-%s-cert", name, args.domainName), &acm.CertificateArgs{
75-
DomainName: pulumi.String(args.domainName),
66+
cert, err := acm.NewCertificate(ctx, fmt.Sprintf("cert-%s", a.StackId), &acm.CertificateArgs{
67+
DomainName: pulumi.String(domainName),
7668
ValidationMethod: pulumi.String("DNS"),
7769
}, defaultOptions...)
7870
if err != nil {
@@ -83,7 +75,7 @@ func newDomainName(ctx *pulumi.Context, name string, args domainNameArgs) (*doma
8375
return options[0]
8476
})
8577

86-
certValidationDns, err := route53.NewRecord(ctx, fmt.Sprintf("%s-%s-certvalidationdns", name, args.domainName), &route53.RecordArgs{
78+
cdnRecord, err := route53.NewRecord(ctx, fmt.Sprintf("cdn-record-%s", a.StackId), &route53.RecordArgs{
8779
Name: domainValidationOption.ApplyT(func(option interface{}) string {
8880
return *option.(acm.CertificateDomainValidationOption).ResourceRecordName
8981
}).(pulumi.StringOutput),
@@ -96,58 +88,16 @@ func newDomainName(ctx *pulumi.Context, name string, args domainNameArgs) (*doma
9688
}).(pulumi.StringOutput),
9789
},
9890
Ttl: pulumi.Int(10 * 60),
99-
ZoneId: pulumi.String(hostedZone.ZoneId),
100-
}, defaultOptions...)
91+
ZoneId: pulumi.String(res.ZoneLookup.ZoneID),
92+
}, []pulumi.ResourceOption{pulumi.Parent(res)}...)
10193
if err != nil {
10294
return nil, err
10395
}
10496

105-
certValidation, err := acm.NewCertificateValidation(ctx, fmt.Sprintf("%s-%s-certvalidation", name, args.domainName), &acm.CertificateValidationArgs{
97+
res.CertificateValidation, err = acm.NewCertificateValidation(ctx, fmt.Sprintf("cert-validation-%s", a.StackId), &acm.CertificateValidationArgs{
10698
CertificateArn: cert.Arn,
10799
ValidationRecordFqdns: pulumi.StringArray{
108-
certValidationDns.Fqdn,
109-
},
110-
}, defaultOptions...)
111-
if err != nil {
112-
return nil, err
113-
}
114-
115-
// Create a domain name if one has been requested
116-
apiDomainName, err := apigatewayv2.NewDomainName(ctx, fmt.Sprintf("%s-%s", name, args.domainName), &apigatewayv2.DomainNameArgs{
117-
DomainName: pulumi.String(args.domainName),
118-
DomainNameConfiguration: &apigatewayv2.DomainNameDomainNameConfigurationArgs{
119-
EndpointType: pulumi.String("REGIONAL"),
120-
SecurityPolicy: pulumi.String("TLS_1_2"),
121-
CertificateArn: certValidation.CertificateArn,
122-
},
123-
}, defaultOptions...)
124-
if err != nil {
125-
return nil, err
126-
}
127-
128-
// Create an API mapping for the new domain name
129-
_, err = apigatewayv2.NewApiMapping(ctx, fmt.Sprintf("%s-%s", name, args.domainName), &apigatewayv2.ApiMappingArgs{
130-
ApiId: args.api.ID(),
131-
DomainName: apiDomainName.DomainName,
132-
Stage: args.stage.Name,
133-
}, append(defaultOptions, pulumi.DependsOn([]pulumi.Resource{args.stage}))...)
134-
if err != nil {
135-
return nil, err
136-
}
137-
138-
// Create a DNS record for the domain name that maps to the APIs
139-
// regional endpoint
140-
_, err = route53.NewRecord(ctx, fmt.Sprintf("%s-%s-dnsrecord", name, args.domainName), &route53.RecordArgs{
141-
ZoneId: pulumi.String(hostedZone.ZoneId),
142-
Type: pulumi.String("A"),
143-
Name: pulumi.String(baseName),
144-
Aliases: &route53.RecordAliasArray{
145-
&route53.RecordAliasArgs{
146-
// The target of the A record
147-
Name: apiDomainName.DomainNameConfiguration.TargetDomainName().Elem(),
148-
ZoneId: apiDomainName.DomainNameConfiguration.HostedZoneId().Elem(),
149-
EvaluateTargetHealth: pulumi.Bool(false),
150-
},
100+
cdnRecord.Fqdn,
151101
},
152102
}, defaultOptions...)
153103
if err != nil {

0 commit comments

Comments
 (0)