Skip to content

Commit 77709c8

Browse files
authored
feat(route53-patterns): use Certificate as the default certificate (under feature flag) (#23575)
The `HttpsRedirect` construct creates a certificate using the `DnsValidatedCertificate` if a certificate is not provided by the user. As part of [deprecating]() the `DnsValidatedCertificate` we need to replace all instances of `DnsValidatedCertificate` with `Certificate`. I have placed this behind a feature flag since I think it should be opt-in behavior for existing users, _but_ it is not a breaking change because of the way CloudFormation works. If the flag is enabled on an existing `HttpsRedirect` CloudFormation will: 1. Create a new certificate using `Certificate` 2. Update CloudFront to use the newly created `Certificate` 3. Delete the old certificate created with the `DnsValidatedCertificate` I have tested this scenario using the two newly created integration tests. ---- ### All Submissions: * [ ] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) ### Adding new Construct Runtime Dependencies: * [ ] This PR adds new construct runtime dependencies following the process described [here](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md/#adding-construct-runtime-dependencies) ### New Features * [ ] Have you added the new feature to an [integration test](https://github.com/aws/aws-cdk/blob/main/INTEGRATION_TESTS.md)? * [ ] Did you use `yarn integ` to deploy the infrastructure and generate the snapshot (i.e. `yarn integ` without `--dry-run`)? *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 0b6b716 commit 77709c8

29 files changed

+2911
-11
lines changed

packages/@aws-cdk/aws-route53-patterns/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,32 @@ new patterns.HttpsRedirect(this, 'Redirect', {
5151
}),
5252
});
5353
```
54+
55+
To have `HttpsRedirect` use the `Certificate` construct as the default
56+
created certificate instead of the deprecated `DnsValidatedCertificate`
57+
construct, enable the `@aws-cdk/aws-route53-patters:useCertificate`
58+
feature flag. If you are creating the stack in a region other than `us-east-1`
59+
you must also enable `crossRegionReferences` on the stack.
60+
61+
```ts
62+
declare const app: App;
63+
const stack = new Stack(app, 'Stack', {
64+
crossRegionReferences: true,
65+
env: {
66+
region: 'us-east-2',
67+
},
68+
});
69+
70+
new patterns.HttpsRedirect(this, 'Redirect', {
71+
recordNames: ['foo.example.com'],
72+
targetDomain: 'bar.example.com',
73+
zone: route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', {
74+
hostedZoneId: 'ID',
75+
zoneName: 'example.com',
76+
}),
77+
});
78+
```
79+
80+
It is safe to upgrade to `@aws-cdk/aws-route53-patterns:useCertificate` since
81+
the new certificate will be created and updated on the CloudFront distribution
82+
before the old certificate is deleted.

packages/@aws-cdk/aws-route53-patterns/lib/website-redirect.ts

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { md5hash } from '@aws-cdk/core/lib/helpers-internal';
2-
import { DnsValidatedCertificate, ICertificate } from '@aws-cdk/aws-certificatemanager';
1+
import { DnsValidatedCertificate, ICertificate, Certificate, CertificateValidation } from '@aws-cdk/aws-certificatemanager';
32
import { CloudFrontWebDistribution, OriginProtocolPolicy, PriceClass, ViewerCertificate, ViewerProtocolPolicy } from '@aws-cdk/aws-cloudfront';
43
import { ARecord, AaaaRecord, IHostedZone, RecordTarget } from '@aws-cdk/aws-route53';
54
import { CloudFrontTarget } from '@aws-cdk/aws-route53-targets';
65
import { BlockPublicAccess, Bucket, RedirectProtocol } from '@aws-cdk/aws-s3';
7-
import { ArnFormat, RemovalPolicy, Stack, Token } from '@aws-cdk/core';
6+
import { ArnFormat, RemovalPolicy, Stack, Token, FeatureFlags } from '@aws-cdk/core';
7+
import { md5hash } from '@aws-cdk/core/lib/helpers-internal';
8+
import { ROUTE53_PATTERNS_USE_CERTIFICATE } from '@aws-cdk/cx-api';
89
import { Construct } from 'constructs';
910

1011
/**
@@ -63,13 +64,7 @@ export class HttpsRedirect extends Construct {
6364
throw new Error(`The certificate must be in the us-east-1 region and the certificate you provided is in ${certificateRegion}.`);
6465
}
6566
}
66-
67-
const redirectCert = props.certificate ?? new DnsValidatedCertificate(this, 'RedirectCertificate', {
68-
domainName: domainNames[0],
69-
subjectAlternativeNames: domainNames,
70-
hostedZone: props.zone,
71-
region: 'us-east-1',
72-
});
67+
const redirectCert = props.certificate ?? this.createCertificate(domainNames, props.zone);
7368

7469
const redirectBucket = new Bucket(this, 'RedirectBucket', {
7570
websiteRedirect: {
@@ -107,4 +102,70 @@ export class HttpsRedirect extends Construct {
107102
new AaaaRecord(this, `RedirectAliasRecordSix${hash}`, aliasProps);
108103
});
109104
}
105+
106+
/**
107+
* Gets the stack to use for creating the Certificate
108+
* If the current stack is not in `us-east-1` then this
109+
* will create a new `us-east-1` stack.
110+
*
111+
* CloudFront is a global resource which you can create (via CloudFormation) from
112+
* _any_ region. So I could create a CloudFront distribution in `us-east-2` if I wanted
113+
* to (maybe the rest of my application lives there). The problem is that some supporting resources
114+
* that CloudFront uses (i.e. ACM Certificates) are required to exist in `us-east-1`. This means
115+
* that if I want to create a CloudFront distribution in `us-east-2` I still need to create a ACM certificate in
116+
* `us-east-1`.
117+
*
118+
* In order to do this correctly we need to know which region the CloudFront distribution is being created in.
119+
* We have two options, either require the user to specify the region or make an assumption if they do not.
120+
* This implementation requires the user specify the region.
121+
*/
122+
private certificateScope(): Construct {
123+
const stack = Stack.of(this);
124+
const parent = stack.node.scope;
125+
if (!parent) {
126+
throw new Error(`Stack ${stack.stackId} must be created in the scope of an App or Stage`);
127+
}
128+
if (Token.isUnresolved(stack.region)) {
129+
throw new Error(`When ${ROUTE53_PATTERNS_USE_CERTIFICATE} is enabled, a region must be defined on the Stack`);
130+
}
131+
if (stack.region !== 'us-east-1') {
132+
const stackId = `certificate-redirect-stack-${stack.node.addr}`;
133+
const certStack = parent.node.tryFindChild(stackId) as Stack;
134+
return certStack ?? new Stack(parent, stackId, {
135+
env: { region: 'us-east-1', account: stack.account },
136+
});
137+
}
138+
return this;
139+
}
140+
141+
/**
142+
* Creates a certificate.
143+
*
144+
* If the `ROUTE53_PATTERNS_USE_CERTIFICATE` feature flag is set then
145+
* this will use the `Certificate` class otherwise it will use the
146+
* `DnsValidatedCertificate` class
147+
*
148+
* This is also safe to upgrade since the new certificate will be created and updated
149+
* on the CloudFront distribution before the old one is deleted.
150+
*/
151+
private createCertificate(domainNames: string[], zone: IHostedZone): ICertificate {
152+
const useCertificate = FeatureFlags.of(this).isEnabled(ROUTE53_PATTERNS_USE_CERTIFICATE);
153+
if (useCertificate) {
154+
// this preserves backwards compatibility. Previously the certificate was always created in `this` scope
155+
// so we need to keep the name the same
156+
const id = (this.certificateScope() === this) ? 'RedirectCertificate' : 'RedirectCertificate'+this.node.addr;
157+
return new Certificate(this.certificateScope(), id, {
158+
domainName: domainNames[0],
159+
subjectAlternativeNames: domainNames,
160+
validation: CertificateValidation.fromDns(zone),
161+
});
162+
} else {
163+
return new DnsValidatedCertificate(this, 'RedirectCertificate', {
164+
domainName: domainNames[0],
165+
subjectAlternativeNames: domainNames,
166+
hostedZone: zone,
167+
region: 'us-east-1',
168+
});
169+
}
170+
}
110171
}

packages/@aws-cdk/aws-route53-patterns/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"@aws-cdk/assertions": "0.0.0",
7777
"@aws-cdk/cdk-build-tools": "0.0.0",
7878
"@aws-cdk/integ-runner": "0.0.0",
79+
"@aws-cdk/integ-tests": "0.0.0",
7980
"@aws-cdk/cfn2ts": "0.0.0",
8081
"@aws-cdk/pkglint": "0.0.0",
8182
"@types/jest": "^27.5.2",
@@ -89,6 +90,7 @@
8990
"@aws-cdk/aws-route53-targets": "0.0.0",
9091
"@aws-cdk/aws-s3": "0.0.0",
9192
"@aws-cdk/core": "0.0.0",
93+
"@aws-cdk/cx-api": "0.0.0",
9294
"@aws-cdk/region-info": "0.0.0",
9395
"constructs": "^10.0.0"
9496
},
@@ -101,6 +103,7 @@
101103
"@aws-cdk/aws-route53-targets": "0.0.0",
102104
"@aws-cdk/aws-s3": "0.0.0",
103105
"@aws-cdk/core": "0.0.0",
106+
"@aws-cdk/cx-api": "0.0.0",
104107
"@aws-cdk/region-info": "0.0.0",
105108
"constructs": "^10.0.0"
106109
},

packages/@aws-cdk/aws-route53-patterns/rosetta/default.ts-fixture

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Fixture with packages imported, but nothing else
22
import { Construct } from 'constructs';
3-
import { Stack } from '@aws-cdk/core';
3+
import { Stack, App } from '@aws-cdk/core';
44
import * as route53 from '@aws-cdk/aws-route53';
55
import * as patterns from '@aws-cdk/aws-route53-patterns';
66

packages/@aws-cdk/aws-route53-patterns/test/bucket-website-target.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Template } from '@aws-cdk/assertions';
22
import { Certificate } from '@aws-cdk/aws-certificatemanager';
33
import { HostedZone } from '@aws-cdk/aws-route53';
44
import { App, Stack } from '@aws-cdk/core';
5+
import { ROUTE53_PATTERNS_USE_CERTIFICATE } from '@aws-cdk/cx-api';
56
import { HttpsRedirect } from '../lib';
67

78
test('create HTTPS redirect', () => {
@@ -152,3 +153,144 @@ test('throws when certificate in region other than us-east-1 is supplied', () =>
152153
});
153154
}).toThrow(/The certificate must be in the us-east-1 region and the certificate you provided is in us-east-2./);
154155
});
156+
157+
describe('Uses Certificate when @aws-cdk/aws-route53-patters:useCertificate=true', () => {
158+
test('explicit different region', () => {
159+
// GIVEN
160+
const app = new App({
161+
context: {
162+
[ROUTE53_PATTERNS_USE_CERTIFICATE]: true,
163+
},
164+
});
165+
166+
// WHEN
167+
const stack = new Stack(app, 'test', { env: { region: 'us-east-2' }, crossRegionReferences: true });
168+
new HttpsRedirect(stack, 'Redirect', {
169+
recordNames: ['foo.example.com'],
170+
targetDomain: 'bar.example.com',
171+
zone: HostedZone.fromHostedZoneAttributes(stack, 'HostedZone', {
172+
hostedZoneId: 'ID',
173+
zoneName: 'example.com',
174+
}),
175+
});
176+
177+
// THEN
178+
const certStack = app.node.findChild(`certificate-redirect-stack-${stack.node.addr}`) as Stack;
179+
Template.fromStack(certStack).hasResourceProperties('AWS::CertificateManager::Certificate', {
180+
DomainName: 'foo.example.com',
181+
});
182+
183+
Template.fromStack(stack).hasResourceProperties('AWS::CloudFront::Distribution', {
184+
DistributionConfig: {
185+
ViewerCertificate: {
186+
AcmCertificateArn: {
187+
'Fn::GetAtt': [
188+
'ExportsReader8B249524',
189+
'/cdk/exports/test/certificateredirectstackc8e2763df63c0f7e0c9afe0394e299bb731e281e8euseast1RefRedirectCertificatec8693e36481e135aa76e35c2db892ec6a33a94c7461E1B6E15A36EB7DA',
190+
],
191+
},
192+
},
193+
},
194+
});
195+
});
196+
197+
test('explicit same region', () => {
198+
// GIVEN
199+
const app = new App({
200+
context: {
201+
[ROUTE53_PATTERNS_USE_CERTIFICATE]: true,
202+
},
203+
});
204+
205+
// WHEN
206+
const stack = new Stack(app, 'test', { env: { region: 'us-east-1' }, crossRegionReferences: true });
207+
new HttpsRedirect(stack, 'Redirect', {
208+
recordNames: ['foo.example.com'],
209+
targetDomain: 'bar.example.com',
210+
zone: HostedZone.fromHostedZoneAttributes(stack, 'HostedZone', {
211+
hostedZoneId: 'ID',
212+
zoneName: 'example.com',
213+
}),
214+
});
215+
216+
// THEN
217+
const certStack = app.node.tryFindChild(`certificate-redirect-stack-${stack.node.addr}`);
218+
expect(certStack).toBeUndefined();
219+
Template.fromStack(stack).hasResourceProperties('AWS::CertificateManager::Certificate', {
220+
DomainName: 'foo.example.com',
221+
});
222+
223+
Template.fromStack(stack).hasResourceProperties('AWS::CloudFront::Distribution', {
224+
DistributionConfig: {
225+
ViewerCertificate: {
226+
AcmCertificateArn: {
227+
Ref: 'RedirectRedirectCertificateB4F2F130',
228+
},
229+
},
230+
},
231+
});
232+
});
233+
234+
test('same support stack used for multiple certificates', () => {
235+
// GIVEN
236+
const app = new App({
237+
context: {
238+
[ROUTE53_PATTERNS_USE_CERTIFICATE]: true,
239+
},
240+
});
241+
242+
// WHEN
243+
const stack = new Stack(app, 'test', { env: { region: 'us-east-2' }, crossRegionReferences: true });
244+
new HttpsRedirect(stack, 'Redirect', {
245+
recordNames: ['foo.example.com'],
246+
targetDomain: 'bar.example.com',
247+
zone: HostedZone.fromHostedZoneAttributes(stack, 'HostedZone', {
248+
hostedZoneId: 'ID',
249+
zoneName: 'example.com',
250+
}),
251+
});
252+
253+
new HttpsRedirect(stack, 'Redirect2', {
254+
recordNames: ['foo2.example.com'],
255+
targetDomain: 'bar2.example.com',
256+
zone: HostedZone.fromHostedZoneAttributes(stack, 'HostedZone2', {
257+
hostedZoneId: 'ID',
258+
zoneName: 'example.com',
259+
}),
260+
});
261+
262+
// THEN
263+
const certStack = app.node.tryFindChild(`certificate-redirect-stack-${stack.node.addr}`) as Stack;
264+
Template.fromStack(certStack).hasResourceProperties('AWS::CertificateManager::Certificate', {
265+
DomainName: 'foo.example.com',
266+
});
267+
Template.fromStack(certStack).hasResourceProperties('AWS::CertificateManager::Certificate', {
268+
DomainName: 'foo2.example.com',
269+
});
270+
});
271+
272+
test('unresolved region throws', () => {
273+
// GIVEN
274+
const app = new App({
275+
context: {
276+
[ROUTE53_PATTERNS_USE_CERTIFICATE]: true,
277+
},
278+
});
279+
280+
// WHEN
281+
const stack = new Stack(app, 'test');
282+
283+
// THEN
284+
expect(() => {
285+
new HttpsRedirect(stack, 'Redirect', {
286+
recordNames: ['foo.example.com'],
287+
targetDomain: 'bar.example.com',
288+
zone: HostedZone.fromHostedZoneAttributes(stack, 'HostedZone', {
289+
hostedZoneId: 'ID',
290+
zoneName: 'example.com',
291+
}),
292+
});
293+
294+
}).toThrow(/When @aws-cdk\/aws-route53-patters:useCertificate is enabled, a region must be defined on the Stack/);
295+
});
296+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"version":"22.0.0"}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"version": "22.0.0",
3+
"files": {
4+
"cfd1c229f1b6f82ba7c98886bbb9cce3f98c3d07fc1cee4b2525494d7cd8d4bf": {
5+
"source": {
6+
"path": "integ-https-redirect-same-region.template.json",
7+
"packaging": "file"
8+
},
9+
"destinations": {
10+
"current_account-us-east-1": {
11+
"bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-us-east-1",
12+
"objectKey": "cfd1c229f1b6f82ba7c98886bbb9cce3f98c3d07fc1cee4b2525494d7cd8d4bf.json",
13+
"region": "us-east-1",
14+
"assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-us-east-1"
15+
}
16+
}
17+
}
18+
},
19+
"dockerImages": {}
20+
}

0 commit comments

Comments
 (0)