From 5ec97e1d5d554667add44373a8dc323433798cbc Mon Sep 17 00:00:00 2001 From: droguljic <1875821+droguljic@users.noreply.github.com> Date: Fri, 28 Nov 2025 17:11:42 +0100 Subject: [PATCH] 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. --- src/v2/components/cloudfront/index.ts | 418 ++++++++++++++++++ .../cloudfront/lb-cache-strategy.ts | 131 ++++++ .../cloudfront/s3-cache-strategy.ts | 115 +++++ src/v2/components/cloudfront/types/index.ts | 10 + 4 files changed, 674 insertions(+) create mode 100644 src/v2/components/cloudfront/index.ts create mode 100644 src/v2/components/cloudfront/lb-cache-strategy.ts create mode 100644 src/v2/components/cloudfront/s3-cache-strategy.ts create mode 100644 src/v2/components/cloudfront/types/index.ts diff --git a/src/v2/components/cloudfront/index.ts b/src/v2/components/cloudfront/index.ts new file mode 100644 index 0000000..9bcd1a9 --- /dev/null +++ b/src/v2/components/cloudfront/index.ts @@ -0,0 +1,418 @@ +import * as aws from '@pulumi/aws'; +import * as pulumi from '@pulumi/pulumi'; +import { commonTags } from '../../../constants'; +import { AcmCertificate } from '../../../components/acm-certificate'; +import { S3CacheStrategy } from './s3-cache-strategy'; +import { LbCacheStrategy } from './lb-cache-strategy'; + +export enum BehaviorType { + S3 = 's3', + LB = 'lb', + CUSTOM = 'custom', +} + +export namespace CloudFront { + type BehaviorBase = { + pathPattern: string; + }; + + export type S3Behavior = BehaviorBase & { + type: BehaviorType.S3; + bucket: pulumi.Input; + websiteConfig: pulumi.Input; + }; + + export type LbBehavior = BehaviorBase & { + type: BehaviorType.LB; + loadBalancer: pulumi.Input; + }; + + export type CustomBehavior = BehaviorBase & { + type: BehaviorType.CUSTOM; + originId: pulumi.Input; + domainName: pulumi.Input; + originProtocolPolicy?: pulumi.Input; + allowedMethods?: pulumi.Input[]>; + cachedMethods?: pulumi.Input[]>; + compress?: pulumi.Input; + defaultRootObject?: pulumi.Input; + cachePolicyId?: pulumi.Input; + originRequestPolicyId?: pulumi.Input; + responseHeadersPolicyId?: pulumi.Input; + }; + + export type Behavior = S3Behavior | LbBehavior | CustomBehavior; + + export type Args = { + /** + * Behavior is a combination of distribution's origin and cache behavior. + * Ordering is important since first encountered behavior is applied, + * matched by path. + * The default behavior, i.e. path pattern `*` or `/*`, must always be last. + * Mapping between behavior and cache is one to one, while origin is mapped + * by ID to filter out duplicates while keeping the last occurrence. + */ + behaviors: Behavior[]; + /** + * Domain name for CloudFront distribution. Implies creation of certificate + * and alias record. Must belong to the provided hosted zone. + * Providing the `certificate` argument has following effects: + * - Certificate creation is skipped + * - Provided certificate must cover the domain name + * Responsibility to ensure mentioned requirements in on the consumer, and + * falling to do so will result in unexpected behavior. + */ + domain?: pulumi.Input; + /** + * Certificate for CloudFront distribution. Domain and alternative domains + * are automatically pulled from the certificate and translated into alias + * records. Domains covered by the certificate, must belong to the provided + * hosted zone. The certificate must be in `us-east-1` region. In a case + * of wildcard certificate the `domain` argument is required. + * Providing the `domain` argument has following effects: + * - Alias records creation, from automatically pulled domains, is skipped + * - Certificate must cover the provided domain name + * Responsibility to ensure mentioned requirements in on the consumer, and + * falling to do so will result in unexpected behavior. + */ + certificate?: pulumi.Input; + /** + * ID of hosted zone is needed when the `domain` or the `certificate` + * arguments are provided. + */ + hostedZoneId?: pulumi.Input; + tags?: pulumi.Input<{ + [key: string]: pulumi.Input; + }>; + }; +} + +export class CloudFront extends pulumi.ComponentResource { + name: string; + distribution: aws.cloudfront.Distribution; + acmCertificate?: AcmCertificate; + + constructor( + name: string, + args: CloudFront.Args, + opts: pulumi.ComponentResourceOptions = {}, + ) { + super('studion:cf:CloudFront', name, args, opts); + + this.name = name; + + const { behaviors, domain, certificate, hostedZoneId, tags } = args; + const hasCustomDomain = domain || certificate; + + if (hasCustomDomain && !hostedZoneId) { + throw new Error( + 'Provide `hostedZoneId` alongside `domain` and/or `certificate`.', + ); + } + + const defaultBehavior = behaviors.at(-1); + const orderedBehaviors = behaviors.slice(0, -1); + + if (!defaultBehavior || !isDefaultBehavior(defaultBehavior)) { + throw new Error('Default behavior must be placed last.'); + } + + if (domain && hostedZoneId && !certificate) { + this.acmCertificate = this.createCertificate({ domain, hostedZoneId }); + } + + const defaultRoot = isS3BehaviorType(defaultBehavior) + ? 'index.html' + : isCustomBehaviorType(defaultBehavior) + ? defaultBehavior.defaultRootObject + : undefined; + + this.distribution = this.createDistribution({ + origins: this.createDistributionOrigins(behaviors), + defaultCache: this.getCacheBehavior(defaultBehavior), + orderedCaches: orderedBehaviors.length + ? orderedBehaviors.map(it => ({ + pathPattern: it.pathPattern, + ...this.getCacheBehavior(it), + })) + : undefined, + domain, + certificate: + certificate || this.acmCertificate + ? pulumi.output(certificate ?? this.acmCertificate!.certificate) + : undefined, + defaultRoot, + tags, + }); + + if (hasCustomDomain && hostedZoneId) { + this.createAliasRecord({ hostedZoneId }); + } + + this.registerOutputs(); + } + + private createDistributionOrigins( + behaviors: CloudFront.Args['behaviors'], + ): pulumi.Output { + return pulumi.all(behaviors.map(b => pulumi.output(b))).apply(entries => { + const origins = entries.map(it => { + if (isS3BehaviorType(it)) { + return getOriginWithDefaults({ + originId: it.bucket.arn, + domainName: it.websiteConfig.websiteEndpoint, + customOriginConfig: { + originProtocolPolicy: 'http-only', + }, + }); + } else if (isLbBehaviorType(it)) { + return getOriginWithDefaults({ + originId: it.loadBalancer.arn, + domainName: it.loadBalancer.dnsName, + }); + } else if (isCustomBehaviorType(it)) { + return getOriginWithDefaults({ + originId: it.originId, + domainName: it.domainName, + customOriginConfig: { + ...(it.originProtocolPolicy + ? { originProtocolPolicy: it.originProtocolPolicy } + : undefined), + }, + }); + } else { + throw new Error( + 'Unknown CloudFront behavior encountered during mapping to distribution origins.', + ); + } + }); + + // Remove duplicates, keeps the last occurrence of the origin + return [...new Map(origins.map(it => [it.originId, it])).values()]; + }); + } + + private getCacheBehavior( + behavior: CloudFront.Behavior, + ): aws.types.input.cloudfront.DistributionDefaultCacheBehavior { + const isDefault = isDefaultBehavior(behavior); + const getStrategyName = (backend: string) => + `${this.name}-${backend}-${isDefault ? 'default' : 'ordered'}-cache-strategy`; + + if (isS3BehaviorType(behavior)) { + const strategy = new S3CacheStrategy( + getStrategyName('s3'), + { pathPattern: behavior.pathPattern, bucket: behavior.bucket }, + { parent: this }, + ); + + return strategy.config; + } else if (isLbBehaviorType(behavior)) { + const strategy = new LbCacheStrategy( + getStrategyName('lb'), + { + pathPattern: behavior.pathPattern, + loadBalancer: behavior.loadBalancer, + }, + { parent: this }, + ); + + return strategy.config; + } else if (isCustomBehaviorType(behavior)) { + return { + targetOriginId: behavior.originId, + allowedMethods: behavior.allowedMethods ?? [ + 'GET', + 'HEAD', + 'OPTIONS', + 'PUT', + 'POST', + 'PATCH', + 'DELETE', + ], + cachedMethods: behavior.cachedMethods ?? ['GET', 'HEAD'], + ...(behavior.compress != null && { compress: behavior.compress }), + viewerProtocolPolicy: 'redirect-to-https', + cachePolicyId: + behavior.cachePolicyId ?? + aws.cloudfront + .getCachePolicyOutput({ name: 'Managed-CachingDisabled' }) + .apply(p => p.id!), + originRequestPolicyId: + behavior.originRequestPolicyId ?? + aws.cloudfront + .getOriginRequestPolicyOutput({ + name: 'Managed-AllViewerExceptHostHeader', + }) + .apply(p => p.id!), + responseHeadersPolicyId: + behavior.responseHeadersPolicyId ?? + aws.cloudfront + .getResponseHeadersPolicyOutput({ + name: 'Managed-SecurityHeadersPolicy', + }) + .apply(p => p.id), + }; + } else { + throw new Error( + 'Unknown CloudFront behavior encountered during mapping to distribution cache behaviors.', + ); + } + } + + private createCertificate({ + domain, + hostedZoneId, + }: Required< + Pick + >): AcmCertificate { + return new AcmCertificate( + `${domain}-acm-certificate`, + { + domain, + hostedZoneId, + }, + { parent: this }, + ); + } + + private createDistribution({ + origins, + defaultCache, + orderedCaches, + domain, + certificate, + defaultRoot, + tags, + }: CreateDistributionArgs): aws.cloudfront.Distribution { + return new aws.cloudfront.Distribution( + `${this.name}-cloudfront-distribution`, + { + enabled: true, + isIpv6Enabled: true, + waitForDeployment: true, + httpVersion: 'http2and3', + ...(defaultRoot && { defaultRootObject: defaultRoot }), + ...(certificate + ? { + aliases: domain + ? [domain] + : pulumi + .all([ + certificate.domainName, + certificate.subjectAlternativeNames, + ]) + .apply(([domain, alternativeDomains]) => [ + domain, + ...alternativeDomains, + ]), + viewerCertificate: { + acmCertificateArn: certificate.arn, + sslSupportMethod: 'sni-only', + minimumProtocolVersion: 'TLSv1.2_2021', + }, + } + : { + viewerCertificate: { + cloudfrontDefaultCertificate: true, + }, + }), + origins, + defaultCacheBehavior: defaultCache, + ...(orderedCaches && { orderedCacheBehaviors: orderedCaches }), + priceClass: 'PriceClass_100', + restrictions: { + geoRestriction: { restrictionType: 'none' }, + }, + tags: { ...commonTags, ...tags }, + }, + { parent: this, aliases: [{ name: `${this.name}-cloudfront` }] }, + ); + } + + private createAliasRecord({ + hostedZoneId, + }: Pick, 'hostedZoneId'>) { + return this.distribution.aliases.apply(aliases => + aliases?.map( + (alias, index) => + new aws.route53.Record( + `${this.name}-cloudfront-alias-record-${index}`, + { + type: 'A', + name: alias, + zoneId: hostedZoneId, + aliases: [ + { + name: this.distribution.domainName, + zoneId: this.distribution.hostedZoneId, + evaluateTargetHealth: true, + }, + ], + }, + { + parent: this, + aliases: [{ name: `${this.name}-cdn-route53-record` }], + }, + ), + ), + ); + } +} + +type CreateDistributionArgs = { + origins: pulumi.Output; + defaultCache: aws.types.input.cloudfront.DistributionDefaultCacheBehavior; + orderedCaches?: aws.types.input.cloudfront.DistributionOrderedCacheBehavior[]; + domain?: pulumi.Input; + certificate?: pulumi.Output; + defaultRoot?: pulumi.Input; + tags: CloudFront.Args['tags']; +}; + +function isDefaultBehavior(value: CloudFront.Behavior) { + return value.pathPattern === '*' || value.pathPattern === '/*'; +} + +function isS3BehaviorType( + value: CloudFront.Behavior, +): value is CloudFront.S3Behavior { + return value.type === BehaviorType.S3; +} + +function isLbBehaviorType( + value: CloudFront.Behavior, +): value is CloudFront.LbBehavior { + return value.type === BehaviorType.LB; +} + +function isCustomBehaviorType( + value: CloudFront.Behavior, +): value is CloudFront.CustomBehavior { + return value.type === BehaviorType.CUSTOM; +} + +function getOriginWithDefaults({ + originId, + domainName, + customOriginConfig, +}: Pick< + aws.types.input.cloudfront.DistributionOrigin, + 'originId' | 'domainName' +> & { + customOriginConfig?: Partial< + aws.types.input.cloudfront.DistributionOrigin['customOriginConfig'] + >; +}): aws.types.input.cloudfront.DistributionOrigin { + return { + originId, + domainName, + customOriginConfig: { + originProtocolPolicy: 'https-only', + httpPort: 80, + httpsPort: 443, + originSslProtocols: ['TLSv1.2'], + ...customOriginConfig, + }, + }; +} diff --git a/src/v2/components/cloudfront/lb-cache-strategy.ts b/src/v2/components/cloudfront/lb-cache-strategy.ts new file mode 100644 index 0000000..16ad1cf --- /dev/null +++ b/src/v2/components/cloudfront/lb-cache-strategy.ts @@ -0,0 +1,131 @@ +import * as aws from '@pulumi/aws'; +import * as pulumi from '@pulumi/pulumi'; +import { CacheStrategy } from './types'; + +export namespace LbCacheStrategy { + export type Args = { + pathPattern: string; + loadBalancer: pulumi.Input; + }; +} + +export class LbCacheStrategy + extends pulumi.ComponentResource + implements CacheStrategy +{ + name: string; + pathPattern: string; + config: aws.types.input.cloudfront.DistributionDefaultCacheBehavior; + cachePolicy: aws.cloudfront.CachePolicy; + responseHeadersPolicy: aws.cloudfront.ResponseHeadersPolicy; + + constructor( + name: string, + args: LbCacheStrategy.Args, + opts: pulumi.ComponentResourceOptions = {}, + ) { + super('studion:cf:LbCacheStrategy', name, args, opts); + + this.name = name; + + const { pathPattern, loadBalancer } = args; + + this.pathPattern = pathPattern; + this.cachePolicy = this.createCachePolicy(); + this.responseHeadersPolicy = this.createResponseHeadersPolicy(); + + this.config = { + ...(pathPattern ? { pathPattern } : undefined), + targetOriginId: pulumi.output(loadBalancer).apply(lb => lb.arn), + viewerProtocolPolicy: 'redirect-to-https', + allowedMethods: [ + 'GET', + 'HEAD', + 'OPTIONS', + 'PUT', + 'POST', + 'PATCH', + 'DELETE', + ], + cachedMethods: ['GET', 'HEAD', 'OPTIONS'], + compress: true, + cachePolicyId: this.cachePolicy.id, + originRequestPolicyId: aws.cloudfront + .getOriginRequestPolicyOutput({ name: 'Managed-AllViewer' }) + .apply(policy => policy.id!), + responseHeadersPolicyId: this.responseHeadersPolicy.id, + }; + + this.registerOutputs(); + } + + private createCachePolicy() { + return new aws.cloudfront.CachePolicy( + `${this.name}-lb-cache-policy`, + { + defaultTtl: 0, + minTtl: 0, + maxTtl: 3600, // 1 hour + parametersInCacheKeyAndForwardedToOrigin: { + cookiesConfig: { + cookieBehavior: 'none', + }, + headersConfig: { + headerBehavior: 'none', + }, + queryStringsConfig: { + queryStringBehavior: 'all', + }, + enableAcceptEncodingGzip: true, + enableAcceptEncodingBrotli: true, + }, + }, + { parent: this }, + ); + } + + private createResponseHeadersPolicy() { + return new aws.cloudfront.ResponseHeadersPolicy( + `${this.name}-lb-res-headers-policy`, + { + customHeadersConfig: { + items: [ + { + header: 'Cache-Control', + value: 'no-store', + override: false, + }, + ], + }, + securityHeadersConfig: { + contentTypeOptions: { + override: true, + }, + frameOptions: { + frameOption: 'SAMEORIGIN', + override: false, + }, + referrerPolicy: { + referrerPolicy: 'strict-origin-when-cross-origin', + override: false, + }, + // instruct browsers to only use HTTPS + strictTransportSecurity: { + accessControlMaxAgeSec: 31536000, // 1 year + includeSubdomains: true, + preload: true, + override: true, + }, + }, + }, + { parent: this }, + ); + } + + public getPathConfig(): aws.types.input.cloudfront.DistributionOrderedCacheBehavior { + return { + pathPattern: this.pathPattern, + ...this.config, + }; + } +} diff --git a/src/v2/components/cloudfront/s3-cache-strategy.ts b/src/v2/components/cloudfront/s3-cache-strategy.ts new file mode 100644 index 0000000..539800c --- /dev/null +++ b/src/v2/components/cloudfront/s3-cache-strategy.ts @@ -0,0 +1,115 @@ +import * as aws from '@pulumi/aws'; +import * as pulumi from '@pulumi/pulumi'; +import { CacheStrategy } from './types'; + +export namespace S3CacheStrategy { + export type Args = { + pathPattern: string; + bucket: pulumi.Input; + }; +} + +export class S3CacheStrategy + extends pulumi.ComponentResource + implements CacheStrategy +{ + name: string; + pathPattern: string; + config: aws.types.input.cloudfront.DistributionDefaultCacheBehavior; + cachePolicy: aws.cloudfront.CachePolicy; + responseHeadersPolicy: aws.cloudfront.ResponseHeadersPolicy; + + constructor( + name: string, + args: S3CacheStrategy.Args, + opts: pulumi.ComponentResourceOptions = {}, + ) { + super('studion:cf:S3CacheStrategy', name, args, opts); + + this.name = name; + + const { pathPattern, bucket } = args; + + this.pathPattern = pathPattern; + this.cachePolicy = this.createCachePolicy(); + this.responseHeadersPolicy = this.createResponseHeadersPolicy(); + + this.config = { + targetOriginId: pulumi.output(bucket).apply(b => b.arn), + viewerProtocolPolicy: 'redirect-to-https', + allowedMethods: ['GET', 'HEAD'], + cachedMethods: ['GET', 'HEAD'], + compress: true, + cachePolicyId: this.cachePolicy.id, + responseHeadersPolicyId: this.responseHeadersPolicy.id, + }; + + this.registerOutputs(); + } + + private createCachePolicy() { + return new aws.cloudfront.CachePolicy( + `${this.name}-s3-cache-policy`, + { + defaultTtl: 86400, // 1 day + minTtl: 60, // 1 minute + maxTtl: 31536000, // 1 year + parametersInCacheKeyAndForwardedToOrigin: { + cookiesConfig: { + cookieBehavior: 'none', + }, + headersConfig: { + headerBehavior: 'none', + }, + queryStringsConfig: { + queryStringBehavior: 'none', + }, + enableAcceptEncodingGzip: true, + enableAcceptEncodingBrotli: true, + }, + }, + { parent: this }, + ); + } + + private createResponseHeadersPolicy() { + return new aws.cloudfront.ResponseHeadersPolicy( + `${this.name}-s3-res-headers-policy`, + { + customHeadersConfig: { + items: [ + { + header: 'Cache-Control', + value: 'no-cache', + override: false, + }, + ], + }, + securityHeadersConfig: { + contentTypeOptions: { + override: true, + }, + frameOptions: { + frameOption: 'DENY', + override: true, + }, + // instruct browsers to only use HTTPS + strictTransportSecurity: { + accessControlMaxAgeSec: 31536000, // 1 year + includeSubdomains: true, + preload: true, + override: true, + }, + }, + }, + { parent: this }, + ); + } + + getPathConfig(): aws.types.input.cloudfront.DistributionOrderedCacheBehavior { + return { + pathPattern: this.pathPattern, + ...this.config, + }; + } +} diff --git a/src/v2/components/cloudfront/types/index.ts b/src/v2/components/cloudfront/types/index.ts new file mode 100644 index 0000000..dc7add0 --- /dev/null +++ b/src/v2/components/cloudfront/types/index.ts @@ -0,0 +1,10 @@ +import * as aws from '@pulumi/aws'; + +export interface CacheStrategy { + pathPattern: string; + config: aws.types.input.cloudfront.DistributionDefaultCacheBehavior; + cachePolicy: aws.cloudfront.CachePolicy; + originRequestPolicy?: aws.cloudfront.OriginRequestPolicy; + responseHeadersPolicy?: aws.cloudfront.ResponseHeadersPolicy; + getPathConfig: () => aws.types.input.cloudfront.DistributionOrderedCacheBehavior; +}