|
| 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