Skip to content

Commit bd7c17c

Browse files
committed
feat: add CloudFront component
Exposing CloudFront as a independent component adds flexibility and supports use cases such as multiple origins per distribution. Three types of behaviors are supported: - S3, tailored for S3 origins with auto-configuration including cache policy oriented towards static assets and response headers policy with security headers. - LB, tailored for ALB origins with auto-configuration including cache policy for dynamic content and response headers policy with security headers. - Custom, configurable option with by default disabled caching.
1 parent b1ef9e9 commit bd7c17c

File tree

4 files changed

+642
-0
lines changed

4 files changed

+642
-0
lines changed
Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
1+
import * as aws from '@pulumi/aws';
2+
import * as pulumi from '@pulumi/pulumi';
3+
import { commonTags } from '../../../constants';
4+
import { AcmCertificate } from '../../../components/acm-certificate';
5+
import { S3CacheStrategy } from './s3-cache-strategy';
6+
import { LbCacheStrategy } from './lb-cache-strategy';
7+
8+
export enum BehaviorType {
9+
S3 = 's3',
10+
LB = 'lb',
11+
CUSTOM = 'custom',
12+
}
13+
14+
export namespace CloudFront {
15+
type BehaviorBase = {
16+
pathPattern: string;
17+
};
18+
19+
export type S3Behavior = BehaviorBase & {
20+
type: BehaviorType.S3;
21+
bucket: pulumi.Input<aws.s3.Bucket>;
22+
websiteConfig: pulumi.Input<aws.s3.BucketWebsiteConfigurationV2>;
23+
};
24+
25+
export type LbBehavior = BehaviorBase & {
26+
type: BehaviorType.LB;
27+
loadBalancer: pulumi.Input<aws.lb.LoadBalancer>;
28+
};
29+
30+
export type CustomBehavior = BehaviorBase & {
31+
type: BehaviorType.CUSTOM;
32+
originId: pulumi.Input<string>;
33+
domainName: pulumi.Input<string>;
34+
originProtocolPolicy?: pulumi.Input<string>;
35+
allowedMethods?: pulumi.Input<pulumi.Input<string>[]>;
36+
cachedMethods?: pulumi.Input<pulumi.Input<string>[]>;
37+
compress?: pulumi.Input<boolean>;
38+
cachePolicyId?: pulumi.Input<string>;
39+
originRequestPolicyId?: pulumi.Input<string>;
40+
responseHeadersPolicyId?: pulumi.Input<string>;
41+
};
42+
43+
export type Behavior = S3Behavior | LbBehavior | CustomBehavior;
44+
45+
export type Args = {
46+
/**
47+
* Behavior is a combination of distribution's origin and cache behavior.
48+
* Ordering is important since first encountered behavior is applied,
49+
* matched by path.
50+
* The default behavior, i.e. path pattern `*` or `/*`, must always be last.
51+
* Mapping between behavior and cache is one to one, while origin is mapped
52+
* by ID to filter out duplicates while keeping the last occurrence.
53+
*/
54+
behaviors: Behavior[];
55+
/**
56+
* Domain name for CloudFront distribution. Implies creation of certificate
57+
* and alias record. In case of subdomain, i.e., not mapped to a hosted
58+
* zone, the `hostedZoneId` argument must be provided.
59+
* Mutually exclusive with `certificate`.
60+
*/
61+
domain?: pulumi.Input<string>;
62+
/**
63+
* ID of hosted zone is needed when the `domain` argument is actually a
64+
* subdomain, meaning there is no hosted zone whose name is the domain.
65+
*/
66+
hostedZoneId?: pulumi.Input<string>;
67+
/**
68+
* Certificate for CloudFront distribution. Domain and alternative domains
69+
* are automatically pulled from the certificate.
70+
* Mutually exclusive with `domain`.
71+
*/
72+
certificate?: pulumi.Input<aws.acm.Certificate>;
73+
tags?: pulumi.Input<{
74+
[key: string]: pulumi.Input<string>;
75+
}>;
76+
};
77+
}
78+
79+
export class CloudFront extends pulumi.ComponentResource {
80+
name: string;
81+
distribution: aws.cloudfront.Distribution;
82+
acmCertificate?: AcmCertificate;
83+
hostedZoneId?: pulumi.Output<string>;
84+
85+
constructor(
86+
name: string,
87+
args: CloudFront.Args,
88+
opts: pulumi.ComponentResourceOptions = {},
89+
) {
90+
super('studion:cf:CloudFront', name, args, opts);
91+
92+
this.name = name;
93+
94+
const { behaviors, domain, hostedZoneId, certificate, tags } = args;
95+
96+
if (domain && certificate) {
97+
throw new Error(
98+
'Provide either `domain` or `certificate`, but not both.',
99+
);
100+
}
101+
102+
const defaultBehavior = behaviors.at(-1);
103+
const orderedBehaviors = behaviors.slice(0, -1);
104+
105+
if (!defaultBehavior || !isDefaultBehavior(defaultBehavior)) {
106+
throw new Error('Default behavior must be placed last.');
107+
}
108+
109+
if (domain) {
110+
this.hostedZoneId = hostedZoneId
111+
? pulumi.output(hostedZoneId)
112+
: aws.route53.getZoneOutput({ name: domain }).apply(zone => zone.id);
113+
this.acmCertificate = this.createCertificate(domain);
114+
}
115+
116+
this.distribution = this.createDistribution({
117+
origins: this.createDistributionOrigins(behaviors),
118+
defaultCache: this.getCacheBehavior(defaultBehavior),
119+
orderedCaches: orderedBehaviors.length
120+
? orderedBehaviors.map(it => ({
121+
pathPattern: it.pathPattern,
122+
...this.getCacheBehavior(it),
123+
}))
124+
: undefined,
125+
hasDefaultRoot: isS3BehaviorType(defaultBehavior),
126+
certificate:
127+
certificate || this.acmCertificate
128+
? pulumi.output(certificate ?? this.acmCertificate!.certificate)
129+
: undefined,
130+
tags,
131+
});
132+
133+
if (domain) {
134+
this.createAliasRecord({ domain });
135+
}
136+
137+
this.registerOutputs();
138+
}
139+
140+
private createDistributionOrigins(
141+
behaviors: CloudFront.Args['behaviors'],
142+
): pulumi.Output<aws.types.input.cloudfront.DistributionOrigin[]> {
143+
return pulumi.all(behaviors.map(b => pulumi.output(b))).apply(entries => {
144+
const origins = entries.map(it => {
145+
if (isS3BehaviorType(it)) {
146+
return getOriginWithDefaults({
147+
originId: it.bucket.arn,
148+
domainName: it.websiteConfig.websiteEndpoint,
149+
customOriginConfig: {
150+
originProtocolPolicy: 'http-only',
151+
},
152+
});
153+
} else if (isLbBehaviorType(it)) {
154+
return getOriginWithDefaults({
155+
originId: it.loadBalancer.arn,
156+
domainName: it.loadBalancer.dnsName,
157+
});
158+
} else if (isCustomBehaviorType(it)) {
159+
return getOriginWithDefaults({
160+
originId: it.originId,
161+
domainName: it.domainName,
162+
customOriginConfig: {
163+
...(it.originProtocolPolicy
164+
? { originProtocolPolicy: it.originProtocolPolicy }
165+
: undefined),
166+
},
167+
});
168+
} else {
169+
throw new Error(
170+
'Unknown CloudFront behavior encountered during mapping to distribution origins.',
171+
);
172+
}
173+
});
174+
175+
// Remove duplicates, keeps the last occurrence of the origin
176+
return [...new Map(origins.map(it => [it.originId, it])).values()];
177+
});
178+
}
179+
180+
private getCacheBehavior(
181+
behavior: CloudFront.Behavior,
182+
): aws.types.input.cloudfront.DistributionDefaultCacheBehavior {
183+
const isDefault = isDefaultBehavior(behavior);
184+
const getStrategyName = (backend: string) =>
185+
`${this.name}-${backend}-${isDefault ? 'default' : 'ordered'}-cache-strategy`;
186+
187+
if (isS3BehaviorType(behavior)) {
188+
const strategy = new S3CacheStrategy(
189+
getStrategyName('s3'),
190+
{ pathPattern: behavior.pathPattern, bucket: behavior.bucket },
191+
{ parent: this },
192+
);
193+
194+
return strategy.config;
195+
} else if (isLbBehaviorType(behavior)) {
196+
const strategy = new LbCacheStrategy(
197+
getStrategyName('lb'),
198+
{
199+
pathPattern: behavior.pathPattern,
200+
loadBalancer: behavior.loadBalancer,
201+
},
202+
{ parent: this },
203+
);
204+
205+
return strategy.config;
206+
} else if (isCustomBehaviorType(behavior)) {
207+
return {
208+
targetOriginId: behavior.originId,
209+
allowedMethods: behavior.allowedMethods ?? [
210+
'GET',
211+
'HEAD',
212+
'OPTIONS',
213+
'PUT',
214+
'POST',
215+
'PATCH',
216+
'DELETE',
217+
],
218+
cachedMethods: behavior.cachedMethods ?? ['GET', 'HEAD'],
219+
...(behavior.compress != null && { compress: behavior.compress }),
220+
viewerProtocolPolicy: 'redirect-to-https',
221+
cachePolicyId:
222+
behavior.cachePolicyId ??
223+
aws.cloudfront
224+
.getCachePolicyOutput({ name: 'Managed-CachingDisabled' })
225+
.apply(p => p.id!),
226+
originRequestPolicyId:
227+
behavior.originRequestPolicyId ??
228+
aws.cloudfront
229+
.getOriginRequestPolicyOutput({ name: 'Managed-AllViewer' })
230+
.apply(p => p.id!),
231+
responseHeadersPolicyId:
232+
behavior.responseHeadersPolicyId ??
233+
aws.cloudfront
234+
.getResponseHeadersPolicyOutput({
235+
name: 'Managed-SecurityHeadersPolicy',
236+
})
237+
.apply(p => p.id),
238+
};
239+
} else {
240+
throw new Error(
241+
'Unknown CloudFront behavior encountered during mapping to distribution cache behaviors.',
242+
);
243+
}
244+
}
245+
246+
private createCertificate(domain: CloudFront.Args['domain']): AcmCertificate {
247+
return new AcmCertificate(
248+
`${domain}-acm-certificate`,
249+
{
250+
domain: domain!,
251+
hostedZoneId: this.hostedZoneId!,
252+
},
253+
{ parent: this },
254+
);
255+
}
256+
257+
private createDistribution({
258+
origins,
259+
defaultCache,
260+
orderedCaches,
261+
certificate,
262+
hasDefaultRoot,
263+
tags,
264+
}: CreateDistributionArgs): aws.cloudfront.Distribution {
265+
return new aws.cloudfront.Distribution(
266+
`${this.name}-cloudfront-distribution`,
267+
{
268+
enabled: true,
269+
isIpv6Enabled: true,
270+
waitForDeployment: true,
271+
httpVersion: 'http2and3',
272+
...(hasDefaultRoot && { defaultRootObject: 'index.html' }),
273+
...(certificate
274+
? {
275+
aliases: pulumi
276+
.all([
277+
certificate.domainName,
278+
certificate.subjectAlternativeNames,
279+
])
280+
.apply(([domain, alternativeDomains]) => [
281+
domain,
282+
...alternativeDomains,
283+
]),
284+
viewerCertificate: {
285+
acmCertificateArn: certificate.arn,
286+
sslSupportMethod: 'sni-only',
287+
minimumProtocolVersion: 'TLSv1.2_2025',
288+
},
289+
}
290+
: {
291+
viewerCertificate: {
292+
cloudfrontDefaultCertificate: true,
293+
},
294+
}),
295+
origins,
296+
defaultCacheBehavior: defaultCache,
297+
...(orderedCaches && { orderedCacheBehaviors: orderedCaches }),
298+
priceClass: 'PriceClass_100',
299+
restrictions: {
300+
geoRestriction: { restrictionType: 'none' },
301+
},
302+
tags: { ...commonTags, ...tags },
303+
},
304+
{ parent: this, aliases: [{ name: `${this.name}-cloudfront` }] },
305+
);
306+
}
307+
308+
private createAliasRecord({
309+
domain,
310+
}: Pick<Required<CloudFront.Args>, 'domain'>) {
311+
return new aws.route53.Record(
312+
`${this.name}-cloudfront-alias-record`,
313+
{
314+
type: 'A',
315+
name: domain,
316+
zoneId: this.hostedZoneId!,
317+
aliases: [
318+
{
319+
name: this.distribution.domainName,
320+
zoneId: this.distribution.hostedZoneId,
321+
evaluateTargetHealth: true,
322+
},
323+
],
324+
},
325+
{ parent: this, aliases: [{ name: `${this.name}-cdn-route53-record` }] },
326+
);
327+
}
328+
}
329+
330+
type CreateDistributionArgs = {
331+
origins: pulumi.Output<aws.types.input.cloudfront.DistributionOrigin[]>;
332+
defaultCache: aws.types.input.cloudfront.DistributionDefaultCacheBehavior;
333+
orderedCaches?: aws.types.input.cloudfront.DistributionOrderedCacheBehavior[];
334+
certificate?: pulumi.Output<aws.acm.Certificate>;
335+
hasDefaultRoot: boolean;
336+
tags: CloudFront.Args['tags'];
337+
};
338+
339+
function isDefaultBehavior(value: CloudFront.Behavior) {
340+
return value.pathPattern === '*' || value.pathPattern === '/';
341+
}
342+
343+
function isS3BehaviorType(
344+
value: CloudFront.Behavior,
345+
): value is CloudFront.S3Behavior {
346+
return value.type === BehaviorType.S3;
347+
}
348+
349+
function isLbBehaviorType(
350+
value: CloudFront.Behavior,
351+
): value is CloudFront.LbBehavior {
352+
return value.type === BehaviorType.LB;
353+
}
354+
355+
function isCustomBehaviorType(
356+
value: CloudFront.Behavior,
357+
): value is CloudFront.CustomBehavior {
358+
return value.type === BehaviorType.CUSTOM;
359+
}
360+
361+
function getOriginWithDefaults({
362+
originId,
363+
domainName,
364+
customOriginConfig,
365+
}: Pick<
366+
aws.types.input.cloudfront.DistributionOrigin,
367+
'originId' | 'domainName'
368+
> & {
369+
customOriginConfig?: Partial<
370+
aws.types.input.cloudfront.DistributionOrigin['customOriginConfig']
371+
>;
372+
}): aws.types.input.cloudfront.DistributionOrigin {
373+
return {
374+
originId,
375+
domainName,
376+
connectionAttempts: 3,
377+
connectionTimeout: 10,
378+
customOriginConfig: {
379+
originProtocolPolicy: 'https-only',
380+
httpPort: 80,
381+
httpsPort: 443,
382+
originSslProtocols: ['TLSv1.2'],
383+
...customOriginConfig,
384+
},
385+
};
386+
}

0 commit comments

Comments
 (0)