Skip to content

Commit ed85a45

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 ed85a45

File tree

4 files changed

+637
-0
lines changed

4 files changed

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

0 commit comments

Comments
 (0)