diff --git a/packages/@aws-cdk/aws-cloudfront/README.md b/packages/@aws-cdk/aws-cloudfront/README.md index 04488d3670742..0ceb2f310733a 100644 --- a/packages/@aws-cdk/aws-cloudfront/README.md +++ b/packages/@aws-cdk/aws-cloudfront/README.md @@ -1,18 +1,137 @@ ## Amazon CloudFront Construct Library + --- -![cfn-resources: Stable](https://img.shields.io/badge/cfn--resources-stable-success.svg?style=for-the-badge) +| Features | Stability | +| --- | --- | +| CFN Resources | ![Stable](https://img.shields.io/badge/stable-success.svg?style=for-the-badge) | +| Higher level constructs for Distribution | ![Experimental](https://img.shields.io/badge/experimental-important.svg?style=for-the-badge) | +| Higher level constructs for CloudFrontWebDistribution | ![Stable](https://img.shields.io/badge/stable-success.svg?style=for-the-badge) | -> All classes with the `Cfn` prefix in this module ([CFN Resources](https://docs.aws.amazon.com/cdk/latest/guide/constructs.html#constructs_lib)) are always stable and safe to use. +> **CFN Resources:** All classes with the `Cfn` prefix in this module ([CFN Resources](https://docs.aws.amazon.com/cdk/latest/guide/constructs.html#constructs_lib)) are always stable and safe to use. -![cdk-constructs: Experimental](https://img.shields.io/badge/cdk--constructs-experimental-important.svg?style=for-the-badge) +> **Experimental:** Higher level constructs in this module that are marked as experimental are under active development. They are subject to non-backward compatible changes or removal in any future version. These are not subject to the [Semantic Versioning](https://semver.org/) model and breaking changes will be announced in the release notes. This means that while you may use them, you may need to update your source code when upgrading to a newer version of this package. -> The APIs of higher level constructs in this module are experimental and under active development. They are subject to non-backward compatible changes or removal in any future version. These are not subject to the [Semantic Versioning](https://semver.org/) model and breaking changes will be announced in the release notes. This means that while you may use them, you may need to update your source code when upgrading to a newer version of this package. +> **Stable:** Higher level constructs in this module that are marked stable will not undergo any breaking changes. They will strictly follow the [Semantic Versioning](https://semver.org/) model. --- +Amazon CloudFront is a web service that speeds up distribution of your static and dynamic web content, such as .html, .css, .js, and image files, to +your users. CloudFront delivers your content through a worldwide network of data centers called edge locations. When a user requests content that +you're serving with CloudFront, the user is routed to the edge location that provides the lowest latency, so that content is delivered with the best +possible performance. + +## Distribution API - Experimental + +![cdk-constructs: Experimental](https://img.shields.io/badge/cdk--constructs-experimental-important.svg?style=for-the-badge) + +The `Distribution` API is currently being built to replace the existing `CloudFrontWebDistribution` API. The `Distribution` API is optimized for the +most common use cases of CloudFront distributions (e.g., single origin and behavior, few customizations) while still providing the ability for more +advanced use cases. The API focuses on simplicity for the common use cases, and convenience methods for creating the behaviors and origins necessary +for more complex use cases. + +### Creating a distribution + +CloudFront distributions deliver your content from one or more origins; an origin is the location where you store the original version of your +content. Origins can be created from S3 buckets or a custom origin (HTTP server). Each distribution has a default behavior which applies to all +requests to that distribution, and routes requests to a primary origin. + +#### From an S3 Bucket + +An S3 bucket can be added as an origin. If the bucket is configured as a website endpoint, the distribution can use S3 redirects and S3 custom error +documents. + +```ts +import * as cloudfront from '@aws-cdk/aws-cloudfront'; + +// Creates a distribution for a S3 bucket. +const myBucket = new s3.Bucket(this, 'myBucket'); +new cloudfront.Distribution(this, 'myDist', { + defaultBehavior: { origin: cloudfront.Origin.fromBucket(myBucket) }, +}); +``` + +The above will treat the bucket differently based on if `IBucket.isWebsite` is set or not. If the bucket is configured as a website, the bucket is +treated as an HTTP origin, and the built-in S3 redirects and error pages can be used. Otherwise, the bucket is handled as a bucket origin and +CloudFront's redirect and error handling will be used. In the latter case, the Origin wil create an origin access identity and grant it access to the +underlying bucket. This can be used in conjunction with a bucket that is not public to require that your users access your content using CloudFront +URLs and not S3 URLs directly. + +### Domain Names and Certificates + +When you create a distribution, CloudFront assigns a domain name for the distribution, for example: `d111111abcdef8.cloudfront.net`; this value can +be retrieved from `distribution.distributionDomainName`. CloudFront distributions use a default certificate (`*.cloudfront.net`) to support HTTPS by +default. If you want to use your own domain name, such as `www.example.com`, you must associate a certificate with your distribution that contains +your domain name. The certificate must be present in the AWS Certificate Manager (ACM) service in the US East (N. Virginia) region; the certificate +may either be created by ACM, or created elsewhere and imported into ACM. + +```ts +const myCertificate = new acm.DnsValidatedCertificate(this, 'mySiteCert', { + domainName: 'www.example.com', + hostedZone, +}); +new cloudfront.Distribution(this, 'myDist', { + defaultBehavior: { origin: cloudfront.Origin.fromBucket(myBucket) }, + certificate: myCertificate, +}); +``` + +### Multiple Behaviors & Origins + +Each distribution has a default behavior which applies to all requests to that distribution; additional behaviors may be specified for a +given URL path pattern. Behaviors allow routing with multiple origins, controlling which HTTP methods to support, whether to require users to +use HTTPS, and what query strings or cookies to forward to your origin, among others. + +The properties of the default behavior can be adjusted as part of the distribution creation. The following example shows configuring the HTTP +methods and viewer protocol policy of the cache. + +```ts +const myWebDistribution = new cloudfront.Distribution(this, 'myDist', { + defaultBehavior: { + origin: cloudfront.Origin.fromBucket(myBucket), + allowedMethods: AllowedMethods.ALLOW_ALL, + viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + } +}); +``` + +Additional behaviors can be specified at creation, or added after the initial creation. Each additional behavior is associated with an origin, +and enable customization for a specific set of resources based on a URL path pattern. For example, we can add a behavior to `myWebDistribution` to +override the default time-to-live (TTL) for all of the images. + +```ts +myWebDistribution.addBehavior('/images/*.jpg', cloudfront.Origin.fromBucket(myOtherBucket), { + viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + defaultTtl: cdk.Duration.days(7), +}); +``` + +These behaviors can also be specified at distribution creation time. + +```ts +const bucketOrigin = cloudfront.Origin.fromBucket(myBucket); +new cloudfront.Distribution(this, 'myDist', { + defaultBehavior: { + origin: bucketOrigin, + allowedMethods: AllowedMethods.ALLOW_ALL, + viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + }, + additionalBehaviors: { + '/images/*.jpg': { + origin: bucketOrigin, + viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + defaultTtl: cdk.Duration.days(7), + }, + }, +}); +``` + +## CloudFrontWebDistribution API - Stable + +![cdk-constructs: Stable](https://img.shields.io/badge/cdk--constructs-stable-success.svg?style=for-the-badge) + A CloudFront construct - for setting up the AWS CDN with ease! Example usage: @@ -75,6 +194,7 @@ CloudFront supports adding restrictions to your distribution. See [Restricting the Geographic Distribution of Your Content](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/georestrictions.html) in the CloudFront User Guide. Example: + ```ts new cloudfront.CloudFrontWebDistribution(stack, 'MyDistribution', { //... @@ -82,7 +202,7 @@ new cloudfront.CloudFrontWebDistribution(stack, 'MyDistribution', { }); ``` -### Connection behaviors between CloudFront and your origin. +### Connection behaviors between CloudFront and your origin CloudFront provides you even more control over the connection behaviors between CloudFront and your origin. You can now configure the number of connection attempts CloudFront will make to your origin and the origin connection timeout for each attempt. diff --git a/packages/@aws-cdk/aws-cloudfront/lib/distribution.ts b/packages/@aws-cdk/aws-cloudfront/lib/distribution.ts index 9b124dd37d722..2290a8c366a2d 100644 --- a/packages/@aws-cdk/aws-cloudfront/lib/distribution.ts +++ b/packages/@aws-cdk/aws-cloudfront/lib/distribution.ts @@ -1,14 +1,354 @@ +import * as acm from '@aws-cdk/aws-certificatemanager'; +import { Construct, IResource, Lazy, Resource, Stack, Token, Duration } from '@aws-cdk/core'; +import { CfnDistribution } from './cloudfront.generated'; +import { Origin } from './origin'; +import { CacheBehavior } from './private/cache-behavior'; + /** * Interface for CloudFront distributions */ -export interface IDistribution { +export interface IDistribution extends IResource { /** - * The domain name of the distribution + * The domain name of the Distribution, such as d111111abcdef8.cloudfront.net. + * + * @attribute + * @deprecated - Use `distributionDomainName` instead. */ readonly domainName: string; + /** + * The domain name of the Distribution, such as d111111abcdef8.cloudfront.net. + * + * @attribute + */ + readonly distributionDomainName: string; + /** * The distribution ID for this distribution. + * + * @attribute */ readonly distributionId: string; -} \ No newline at end of file +} + +/** + * Attributes used to import a Distribution. + * + * @experimental + */ +export interface DistributionAttributes { + /** + * The generated domain name of the Distribution, such as d111111abcdef8.cloudfront.net. + * + * @attribute + */ + readonly domainName: string; + + /** + * The distribution ID for this distribution. + * + * @attribute + */ + readonly distributionId: string; +} + +/** + * Properties for a Distribution + * + * @experimental + */ +export interface DistributionProps { + /** + * The default behavior for the distribution. + */ + readonly defaultBehavior: BehaviorOptions; + + /** + * Additional behaviors for the distribution, mapped by the pathPattern that specifies which requests to apply the behavior to. + * + * @default - no additional behaviors are added. + */ + readonly additionalBehaviors?: Record; + + /** + * A certificate to associate with the distribution. The certificate must be located in N. Virginia (us-east-1). + * + * @default - the CloudFront wildcard certificate (*.cloudfront.net) will be used. + */ + readonly certificate?: acm.ICertificate; + + /** + * The price class that corresponds with the maximum price that you want to pay for CloudFront service. + * If you specify PriceClass_All, CloudFront responds to requests for your objects from all CloudFront edge locations. + * If you specify a price class other than PriceClass_All, CloudFront serves your objects from the CloudFront edge location + * that has the lowest latency among the edge locations in your price class. + * + * @default PriceClass.PRICE_CLASS_ALL + */ + readonly priceClass?: PriceClass; + + /** + * How CloudFront should handle requests that are not successful (e.g., PageNotFound). + * + * @default - No custom error responses. + */ + readonly errorResponses?: ErrorResponse[]; +} + +/** + * A CloudFront distribution with associated origin(s) and caching behavior(s). + * + * @experimental + */ +export class Distribution extends Resource implements IDistribution { + + /** + * Creates a Distribution construct that represents an external (imported) distribution. + */ + public static fromDistributionAttributes(scope: Construct, id: string, attrs: DistributionAttributes): IDistribution { + return new class extends Resource implements IDistribution { + public readonly domainName: string; + public readonly distributionDomainName: string; + public readonly distributionId: string; + + constructor() { + super(scope, id); + this.domainName = attrs.domainName; + this.distributionDomainName = attrs.domainName; + this.distributionId = attrs.distributionId; + } + }(); + } + + public readonly domainName: string; + public readonly distributionDomainName: string; + public readonly distributionId: string; + + private readonly defaultBehavior: CacheBehavior; + private readonly additionalBehaviors: CacheBehavior[] = []; + private readonly origins: Set = new Set(); + + private readonly errorResponses: ErrorResponse[]; + private readonly certificate?: acm.ICertificate; + + constructor(scope: Construct, id: string, props: DistributionProps) { + super(scope, id); + + if (props.certificate) { + const certificateRegion = Stack.of(this).parseArn(props.certificate.certificateArn).region; + if (!Token.isUnresolved(certificateRegion) && certificateRegion !== 'us-east-1') { + throw new Error('Distribution certificates must be in the us-east-1 region and the certificate you provided is in $Region.'); + } + } + + this.defaultBehavior = new CacheBehavior({ pathPattern: '*', ...props.defaultBehavior }); + this.addOrigin(this.defaultBehavior.origin); + if (props.additionalBehaviors) { + Object.entries(props.additionalBehaviors).forEach(([pathPattern, behaviorOptions]) => { + this.addBehavior(pathPattern, behaviorOptions.origin, behaviorOptions); + }); + } + + this.certificate = props.certificate; + this.errorResponses = props.errorResponses ?? []; + + const distribution = new CfnDistribution(this, 'CFDistribution', { distributionConfig: { + enabled: true, + origins: Lazy.anyValue({ produce: () => this.renderOrigins() }), + defaultCacheBehavior: this.defaultBehavior._renderBehavior(), + cacheBehaviors: Lazy.anyValue({ produce: () => this.renderCacheBehaviors() }), + viewerCertificate: this.certificate ? { acmCertificateArn: this.certificate.certificateArn } : undefined, + customErrorResponses: this.renderErrorResponses(), + priceClass: props.priceClass ?? undefined, + } }); + + this.domainName = distribution.attrDomainName; + this.distributionDomainName = distribution.attrDomainName; + this.distributionId = distribution.ref; + } + + /** + * Adds a new behavior to this distribution for the given pathPattern. + * + * @param pathPattern the path pattern (e.g., 'images/*') that specifies which requests to apply the behavior to. + * @param behaviorOptions the options for the behavior at this path. + */ + public addBehavior(pathPattern: string, origin: Origin, behaviorOptions: AddBehaviorOptions = {}) { + if (pathPattern === '*') { + throw new Error('Only the default behavior can have a path pattern of \'*\''); + } + this.additionalBehaviors.push(new CacheBehavior({ pathPattern, origin, ...behaviorOptions })); + this.addOrigin(origin); + } + + private addOrigin(origin: Origin) { + if (!this.origins.has(origin)) { + this.origins.add(origin); + origin._bind(this, { originIndex: this.origins.size }); + } + } + + private renderOrigins(): CfnDistribution.OriginProperty[] { + const renderedOrigins: CfnDistribution.OriginProperty[] = []; + this.origins.forEach(origin => renderedOrigins.push(origin._renderOrigin())); + return renderedOrigins; + } + + private renderCacheBehaviors(): CfnDistribution.CacheBehaviorProperty[] | undefined { + if (this.additionalBehaviors.length === 0) { return undefined; } + return this.additionalBehaviors.map(behavior => behavior._renderBehavior()); + } + + private renderErrorResponses(): CfnDistribution.CustomErrorResponseProperty[] | undefined { + if (this.errorResponses.length === 0) { return undefined; } + function validateCustomErrorResponse(errorResponse: ErrorResponse) { + if (errorResponse.responsePagePath && !errorResponse.responseHttpStatus) { + throw new Error('\'responseCode\' must be provided if \'responsePagePath\' is defined'); + } + if (!errorResponse.responseHttpStatus && !errorResponse.ttl) { + throw new Error('A custom error response without either a \'responseCode\' or \'errorCachingMinTtl\' is not valid.'); + } + } + this.errorResponses.forEach(e => validateCustomErrorResponse(e)); + + return this.errorResponses.map(errorConfig => { + return { + errorCachingMinTtl: errorConfig.ttl?.toSeconds(), + errorCode: errorConfig.httpStatus, + responseCode: errorConfig.responseHttpStatus, + responsePagePath: errorConfig.responsePagePath, + }; + }); + } + +} + +/** + * The price class determines how many edge locations CloudFront will use for your distribution. + * See https://aws.amazon.com/cloudfront/pricing/ for full list of supported regions. + */ +export enum PriceClass { + /** USA, Canada, Europe, & Israel */ + PRICE_CLASS_100 = 'PriceClass_100', + /** PRICE_CLASS_100 + South Africa, Kenya, Middle East, Japan, Singapore, South Korea, Taiwan, Hong Kong, & Philippines */ + PRICE_CLASS_200 = 'PriceClass_200', + /** All locations */ + PRICE_CLASS_ALL = 'PriceClass_All' +} + +/** + * How HTTPs should be handled with your distribution. + */ +export enum ViewerProtocolPolicy { + /** HTTPS only */ + HTTPS_ONLY = 'https-only', + /** Will redirect HTTP requests to HTTPS */ + REDIRECT_TO_HTTPS = 'redirect-to-https', + /** Both HTTP and HTTPS supported */ + ALLOW_ALL = 'allow-all' +} + +/** + * Defines what protocols CloudFront will use to connect to an origin. + */ +export enum OriginProtocolPolicy { + /** Connect on HTTP only */ + HTTP_ONLY = 'http-only', + /** Connect with the same protocol as the viewer */ + MATCH_VIEWER = 'match-viewer', + /** Connect on HTTPS only */ + HTTPS_ONLY = 'https-only', +} + +/** + * The HTTP methods that the Behavior will accept requests on. + */ +export class AllowedMethods { + /** HEAD and GET */ + public static readonly ALLOW_GET_HEAD = new AllowedMethods(['GET', 'HEAD']); + /** HEAD, GET, and OPTIONS */ + public static readonly ALLOW_GET_HEAD_OPTIONS = new AllowedMethods(['GET', 'HEAD', 'OPTIONS']); + /** All supported HTTP methods */ + public static readonly ALLOW_ALL = new AllowedMethods(['GET', 'HEAD', 'OPTIONS', 'PUT', 'PATCH', 'POST', 'DELETE']); + + /** HTTP methods supported */ + public readonly methods: string[]; + + private constructor(methods: string[]) { this.methods = methods; } +} + +/** + * Options for configuring custom error responses. + * + * @experimental + */ +export interface ErrorResponse { + /** + * The minimum amount of time, in seconds, that you want CloudFront to cache the HTTP status code specified in ErrorCode. + * + * @default - the default caching TTL behavior applies + */ + readonly ttl?: Duration; + /** + * The HTTP status code for which you want to specify a custom error page and/or a caching duration. + */ + readonly httpStatus: number; + /** + * The HTTP status code that you want CloudFront to return to the viewer along with the custom error page. + * + * If you specify a value for `responseHttpStatus`, you must also specify a value for `responsePagePath`. + * + * @default - not set, the error code will be returned as the response code. + */ + readonly responseHttpStatus?: number; + /** + * The path to the custom error page that you want CloudFront to return to a viewer when your origin returns the + * `httpStatus`, for example, /4xx-errors/403-forbidden.html + * + * @default - the default CloudFront response is shown. + */ + readonly responsePagePath?: string; +} + +/** + * Options for adding a new behavior to a Distribution. + * + * @experimental + */ +export interface AddBehaviorOptions { + /** + * HTTP methods to allow for this behavior. + * + * @default - GET and HEAD + */ + readonly allowedMethods?: AllowedMethods; + + /** + * Whether CloudFront will forward query strings to the origin. + * If this is set to true, CloudFront will forward all query parameters to the origin, and cache + * based on all parameters. See `forwardQueryStringCacheKeys` for a way to limit the query parameters + * CloudFront caches on. + * + * @default false + */ + readonly forwardQueryString?: boolean; + + /** + * A set of query string parameter names to use for caching if `forwardQueryString` is set to true. + * + * @default [] + */ + readonly forwardQueryStringCacheKeys?: string[]; +} + +/** + * Options for creating a new behavior. + * + * @experimental + */ +export interface BehaviorOptions extends AddBehaviorOptions { + /** + * The origin that you want CloudFront to route requests to when they match this behavior. + */ + readonly origin: Origin; +} diff --git a/packages/@aws-cdk/aws-cloudfront/lib/index.ts b/packages/@aws-cdk/aws-cloudfront/lib/index.ts index 85b081a3f9e9a..bf106211657c9 100644 --- a/packages/@aws-cdk/aws-cloudfront/lib/index.ts +++ b/packages/@aws-cdk/aws-cloudfront/lib/index.ts @@ -1,5 +1,6 @@ export * from './distribution'; export * from './web_distribution'; +export * from './origin'; export * from './origin_access_identity'; // AWS::CloudFront CloudFormation Resources: diff --git a/packages/@aws-cdk/aws-cloudfront/lib/origin.ts b/packages/@aws-cdk/aws-cloudfront/lib/origin.ts new file mode 100644 index 0000000000000..f876c71bdf4b2 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudfront/lib/origin.ts @@ -0,0 +1,195 @@ +import { IBucket } from '@aws-cdk/aws-s3'; +import { Construct } from '@aws-cdk/core'; +import { CfnDistribution } from './cloudfront.generated'; +import { OriginProtocolPolicy } from './distribution'; +import { OriginAccessIdentity } from './origin_access_identity'; + +/** + * Properties to be used to create an Origin. Prefer to use one of the Origin.from* factory methods rather than + * instantiating an Origin directly from these properties. + * + * @experimental + */ +export interface OriginProps { + /** + * The domain name of the Amazon S3 bucket or HTTP server origin. + */ + readonly domainName: string; +} + +/** + * Options passed to Origin.bind(). + */ +interface OriginBindOptions { + /** + * The positional index of this origin within the distribution. Used for ensuring unique IDs. + */ + readonly originIndex: number; +} + +/** + * Represents a distribution origin, that describes the Amazon S3 bucket, HTTP server (for example, a web server), + * Amazon MediaStore, or other server from which CloudFront gets your files. + * + * @experimental + */ +export abstract class Origin { + + /** + * Creates a pre-configured origin for a S3 bucket. + * If this bucket has been configured for static website hosting, then `fromWebsiteBucket` should be used instead. + * + * An Origin Access Identity will be created and granted read access to the bucket. + * + * @param bucket the bucket to act as an origin. + */ + public static fromBucket(bucket: IBucket): Origin { + if (bucket.isWebsite) { + return new HttpOrigin({ + domainName: bucket.bucketWebsiteDomainName, + protocolPolicy: OriginProtocolPolicy.HTTP_ONLY, // S3 only supports HTTP for website buckets + }); + } else { + return new S3Origin({ domainName: bucket.bucketRegionalDomainName, bucket }); + } + } + + /** + * The domain name of the origin. + */ + public readonly domainName: string; + + private originId!: string; + + constructor(props: OriginProps) { + this.domainName = props.domainName; + } + + /** + * The unique id for this origin. + * + * Cannot be accesed until bind() is called. + */ + public get id(): string { + if (!this.originId) { throw new Error('Cannot access originId until `bind` is called.'); } + return this.originId; + } + + /** + * Binds the origin to the associated Distribution. Can be used to grant permissions, create dependent resources, etc. + * + * @internal + */ + public _bind(scope: Construct, options: OriginBindOptions): void { + this.originId = new Construct(scope, `Origin${options.originIndex}`).node.uniqueId; + } + + /** + * Creates and returns the CloudFormation representation of this origin. + * + * @internal + */ + public _renderOrigin(): CfnDistribution.OriginProperty { + const s3OriginConfig = this.renderS3OriginConfig(); + const customOriginConfig = this.renderCustomOriginConfig(); + + if (!s3OriginConfig && !customOriginConfig) { + throw new Error('Subclass must override and provide either s3OriginConfig or customOriginConfig'); + } + + return { + domainName: this.domainName, + id: this.id, + s3OriginConfig, + customOriginConfig, + }; + } + + // Overridden by sub-classes to provide S3 origin config. + protected renderS3OriginConfig(): CfnDistribution.S3OriginConfigProperty | undefined { + return undefined; + } + + // Overridden by sub-classes to provide custom origin config. + protected renderCustomOriginConfig(): CfnDistribution.CustomOriginConfigProperty | undefined { + return undefined; + } + +} + +/** + * Properties for an Origin backed by an S3 bucket + * + * @experimental + */ +export interface S3OriginProps extends OriginProps { + /** + * The bucket to use as an origin. + */ + readonly bucket: IBucket; +} + +/** + * An Origin specific to a S3 bucket (not configured for website hosting). + * + * Contains additional logic around bucket permissions and origin access identities. + * + * @experimental + */ +export class S3Origin extends Origin { + private readonly bucket: IBucket; + private originAccessIdentity!: OriginAccessIdentity; + + constructor(props: S3OriginProps) { + super(props); + this.bucket = props.bucket; + } + + /** @internal */ + public _bind(scope: Construct, options: OriginBindOptions) { + super._bind(scope, options); + if (!this.originAccessIdentity) { + this.originAccessIdentity = new OriginAccessIdentity(scope, `S3Origin${options.originIndex}`); + this.bucket.grantRead(this.originAccessIdentity); + } + } + + protected renderS3OriginConfig(): CfnDistribution.S3OriginConfigProperty | undefined { + return { originAccessIdentity: `origin-access-identity/cloudfront/${this.originAccessIdentity.originAccessIdentityName}` }; + } +} + +/** + * Properties for an Origin backed by an S3 website-configured bucket, load balancer, or custom HTTP server. + * + * @experimental + */ +export interface HttpOriginProps extends OriginProps { + /** + * Specifies the protocol (HTTP or HTTPS) that CloudFront uses to connect to the origin. + * + * @default OriginProtocolPolicy.HTTPS_ONLY + */ + readonly protocolPolicy?: OriginProtocolPolicy; +} + +/** + * An Origin for an HTTP server or S3 bucket configured for website hosting. + * + * @experimental + */ +export class HttpOrigin extends Origin { + + private readonly protocolPolicy?: OriginProtocolPolicy; + + constructor(props: HttpOriginProps) { + super(props); + this.protocolPolicy = props.protocolPolicy; + } + + protected renderCustomOriginConfig(): CfnDistribution.CustomOriginConfigProperty | undefined { + return { + originProtocolPolicy: this.protocolPolicy ?? OriginProtocolPolicy.HTTPS_ONLY, + }; + } +} diff --git a/packages/@aws-cdk/aws-cloudfront/lib/private/cache-behavior.ts b/packages/@aws-cdk/aws-cloudfront/lib/private/cache-behavior.ts new file mode 100644 index 0000000000000..59f0226a92a31 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudfront/lib/private/cache-behavior.ts @@ -0,0 +1,55 @@ +import { CfnDistribution } from '../cloudfront.generated'; +import { BehaviorOptions, ViewerProtocolPolicy } from '../distribution'; +import { Origin } from '../origin'; + +/** + * Properties for specifying custom behaviors for origins. + */ +export interface CacheBehaviorProps extends BehaviorOptions { + /** + * The pattern (e.g., `images/*.jpg`) that specifies which requests to apply the behavior to. + * There must be exactly one behavior associated with each `Distribution` that has a path pattern + * of '*', which acts as the catch-all default behavior. + */ + readonly pathPattern: string; +} + +/** + * Allows configuring a variety of CloudFront functionality for a given URL path pattern. + * + * Note: This really should simply by called 'Behavior', but this name is already taken by the legacy + * CloudFrontWebDistribution implementation. + */ +export class CacheBehavior { + + /** + * Origin that this behavior will route traffic to. + */ + public readonly origin: Origin; + + constructor(private readonly props: CacheBehaviorProps) { + this.origin = props.origin; + } + + /** + * Creates and returns the CloudFormation representation of this behavior. + * This renders as a "CacheBehaviorProperty" regardless of if this is a default + * cache behavior or not, as the two are identical except that the pathPattern + * is omitted for the default cache behavior. + * + * @internal + */ + public _renderBehavior(): CfnDistribution.CacheBehaviorProperty { + return { + pathPattern: this.props.pathPattern, + targetOriginId: this.origin.id, + allowedMethods: this.props.allowedMethods?.methods ?? undefined, + forwardedValues: { + queryString: this.props.forwardQueryString ?? false, + queryStringCacheKeys: this.props.forwardQueryStringCacheKeys, + }, + viewerProtocolPolicy: ViewerProtocolPolicy.ALLOW_ALL, + }; + } + +} diff --git a/packages/@aws-cdk/aws-cloudfront/lib/web_distribution.ts b/packages/@aws-cdk/aws-cloudfront/lib/web_distribution.ts index 1359a91bde3be..e25bfce74409a 100644 --- a/packages/@aws-cdk/aws-cloudfront/lib/web_distribution.ts +++ b/packages/@aws-cdk/aws-cloudfront/lib/web_distribution.ts @@ -4,7 +4,7 @@ import * as lambda from '@aws-cdk/aws-lambda'; import * as s3 from '@aws-cdk/aws-s3'; import * as cdk from '@aws-cdk/core'; import { CfnDistribution } from './cloudfront.generated'; -import { IDistribution } from './distribution'; +import { IDistribution, OriginProtocolPolicy, PriceClass, ViewerProtocolPolicy } from './distribution'; import { IOriginAccessIdentity } from './origin_access_identity'; export enum HttpVersion { @@ -12,24 +12,6 @@ export enum HttpVersion { HTTP2 = 'http2' } -/** - * The price class determines how many edge locations CloudFront will use for your distribution. - */ -export enum PriceClass { - PRICE_CLASS_100 = 'PriceClass_100', - PRICE_CLASS_200 = 'PriceClass_200', - PRICE_CLASS_ALL = 'PriceClass_All' -} - -/** - * How HTTPs should be handled with your distribution. - */ -export enum ViewerProtocolPolicy { - HTTPS_ONLY = 'https-only', - REDIRECT_TO_HTTPS = 'redirect-to-https', - ALLOW_ALL = 'allow-all' -} - /** * Configuration for custom domain names * @@ -247,12 +229,6 @@ export enum OriginSslPolicy { TLS_V1_2 = 'TLSv1.2', } -export enum OriginProtocolPolicy { - HTTP_ONLY = 'http-only', - MATCH_VIEWER = 'match-viewer', - HTTPS_ONLY = 'https-only', -} - /** * S3 origin configuration for CloudFront */ @@ -689,9 +665,9 @@ interface BehaviorWithOrigin extends Behavior { * * You can customize the distribution using additional properties from the CloudFrontWebDistributionProps interface. * - * + * @resource AWS::CloudFront::Distribution */ -export class CloudFrontWebDistribution extends cdk.Construct implements IDistribution { +export class CloudFrontWebDistribution extends cdk.Resource implements IDistribution { /** * The logging bucket for this CloudFront distribution. * If logging is not enabled for this distribution - this property will be undefined. @@ -701,10 +677,19 @@ export class CloudFrontWebDistribution extends cdk.Construct implements IDistrib /** * The domain name created by CloudFront for this distribution. * If you are using aliases for your distribution, this is the domainName your DNS records should point to. - * (In Route53, you could create an ALIAS record to this value, for example. ) + * (In Route53, you could create an ALIAS record to this value, for example.) + * + * @deprecated - Use `distributionDomainName` instead. */ public readonly domainName: string; + /** + * The domain name created by CloudFront for this distribution. + * If you are using aliases for your distribution, this is the domainName your DNS records should point to. + * (In Route53, you could create an ALIAS record to this value, for example.) + */ + public readonly distributionDomainName: string; + /** * The distribution ID for this distribution. */ @@ -921,6 +906,7 @@ export class CloudFrontWebDistribution extends cdk.Construct implements IDistrib const distribution = new CfnDistribution(this, 'CFDistribution', { distributionConfig }); this.node.defaultChild = distribution; this.domainName = distribution.attrDomainName; + this.distributionDomainName = distribution.attrDomainName; this.distributionId = distribution.ref; } diff --git a/packages/@aws-cdk/aws-cloudfront/package.json b/packages/@aws-cdk/aws-cloudfront/package.json index 66b7cf0a30f70..e8d8acc57c8f3 100644 --- a/packages/@aws-cdk/aws-cloudfront/package.json +++ b/packages/@aws-cdk/aws-cloudfront/package.json @@ -94,11 +94,23 @@ "node": ">= 10.13.0 <13 || >=13.7.0" }, "stability": "experimental", - "maturity": "experimental", + "features": [ + { + "name": "Higher level constructs for Distribution", + "stability": "Experimental" + }, + { + "name": "Higher level constructs for CloudFrontWebDistribution", + "stability": "Stable" + } + ], "awslint": { "exclude": [ + "props-physical-name:@aws-cdk/aws-cloudfront.Distribution", + "props-physical-name:@aws-cdk/aws-cloudfront.DistributionProps", + "props-physical-name:@aws-cdk/aws-cloudfront.CloudFrontWebDistribution", + "props-physical-name:@aws-cdk/aws-cloudfront.CloudFrontWebDistributionProps", "props-physical-name:@aws-cdk/aws-cloudfront.OriginAccessIdentityProps", - "docs-public-apis:@aws-cdk/aws-cloudfront.OriginProtocolPolicy", "docs-public-apis:@aws-cdk/aws-cloudfront.ViewerProtocolPolicy.ALLOW_ALL", "docs-public-apis:@aws-cdk/aws-cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS", "props-default-doc:@aws-cdk/aws-cloudfront.Behavior.isDefaultBehavior", @@ -118,18 +130,11 @@ "docs-public-apis:@aws-cdk/aws-cloudfront.HttpVersion.HTTP1_1", "docs-public-apis:@aws-cdk/aws-cloudfront.HttpVersion.HTTP2", "docs-public-apis:@aws-cdk/aws-cloudfront.LambdaEdgeEventType", - "docs-public-apis:@aws-cdk/aws-cloudfront.ViewerProtocolPolicy.HTTPS_ONLY", - "docs-public-apis:@aws-cdk/aws-cloudfront.OriginProtocolPolicy.HTTP_ONLY", - "docs-public-apis:@aws-cdk/aws-cloudfront.OriginProtocolPolicy.MATCH_VIEWER", - "docs-public-apis:@aws-cdk/aws-cloudfront.OriginProtocolPolicy.HTTPS_ONLY", "docs-public-apis:@aws-cdk/aws-cloudfront.OriginSslPolicy", "docs-public-apis:@aws-cdk/aws-cloudfront.OriginSslPolicy.SSL_V3", "docs-public-apis:@aws-cdk/aws-cloudfront.OriginSslPolicy.TLS_V1", "docs-public-apis:@aws-cdk/aws-cloudfront.OriginSslPolicy.TLS_V1_1", "docs-public-apis:@aws-cdk/aws-cloudfront.OriginSslPolicy.TLS_V1_2", - "docs-public-apis:@aws-cdk/aws-cloudfront.PriceClass.PRICE_CLASS_100", - "docs-public-apis:@aws-cdk/aws-cloudfront.PriceClass.PRICE_CLASS_200", - "docs-public-apis:@aws-cdk/aws-cloudfront.PriceClass.PRICE_CLASS_ALL", "docs-public-apis:@aws-cdk/aws-cloudfront.SSLMethod.SNI", "docs-public-apis:@aws-cdk/aws-cloudfront.SSLMethod.VIP", "docs-public-apis:@aws-cdk/aws-cloudfront.SecurityPolicyProtocol.SSL_V3", diff --git a/packages/@aws-cdk/aws-cloudfront/test/distribution.test.ts b/packages/@aws-cdk/aws-cloudfront/test/distribution.test.ts new file mode 100644 index 0000000000000..86c91c2254cf8 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudfront/test/distribution.test.ts @@ -0,0 +1,300 @@ +import '@aws-cdk/assert/jest'; +import * as acm from '@aws-cdk/aws-certificatemanager'; +import * as s3 from '@aws-cdk/aws-s3'; +import { App, Duration, Stack } from '@aws-cdk/core'; +import { Distribution, Origin, PriceClass } from '../lib'; + +let app: App; +let stack: Stack; + +beforeEach(() => { + app = new App(); + stack = new Stack(app, 'Stack', { + env: { account: '1234', region: 'testregion' }, + }); +}); + +test('minimal example renders correctly', () => { + const origin = Origin.fromBucket(new s3.Bucket(stack, 'Bucket')); + new Distribution(stack, 'MyDist', { defaultBehavior: { origin } }); + + expect(stack).toHaveResource('AWS::CloudFront::Distribution', { + DistributionConfig: { + DefaultCacheBehavior: { + ForwardedValues: { QueryString: false }, + TargetOriginId: 'StackMyDistOrigin1D6D5E535', + ViewerProtocolPolicy: 'allow-all', + }, + Enabled: true, + Origins: [{ + DomainName: { 'Fn::GetAtt': [ 'Bucket83908E77', 'RegionalDomainName' ] }, + Id: 'StackMyDistOrigin1D6D5E535', + S3OriginConfig: { + OriginAccessIdentity: { 'Fn::Join': [ '', + [ 'origin-access-identity/cloudfront/', { Ref: 'MyDistS3Origin1ED86A27E' } ], + ]}, + }, + }], + }, + }); +}); + +describe('multiple behaviors', () => { + + test('a second behavior can\'t be specified with the catch-all path pattern', () => { + const origin = Origin.fromBucket(new s3.Bucket(stack, 'Bucket')); + + expect(() => { + new Distribution(stack, 'MyDist', { + defaultBehavior: { origin }, + additionalBehaviors: { + '*': { origin }, + }, + }); + }).toThrow(/Only the default behavior can have a path pattern of \'*\'/); + }); + + test('a second behavior can be added to the original origin', () => { + const origin = Origin.fromBucket(new s3.Bucket(stack, 'Bucket')); + new Distribution(stack, 'MyDist', { + defaultBehavior: { origin }, + additionalBehaviors: { + 'api/*': { origin }, + }, + }); + + expect(stack).toHaveResource('AWS::CloudFront::Distribution', { + DistributionConfig: { + DefaultCacheBehavior: { + ForwardedValues: { QueryString: false }, + TargetOriginId: 'StackMyDistOrigin1D6D5E535', + ViewerProtocolPolicy: 'allow-all', + }, + CacheBehaviors: [{ + PathPattern: 'api/*', + ForwardedValues: { QueryString: false }, + TargetOriginId: 'StackMyDistOrigin1D6D5E535', + ViewerProtocolPolicy: 'allow-all', + }], + Enabled: true, + Origins: [{ + DomainName: { 'Fn::GetAtt': [ 'Bucket83908E77', 'RegionalDomainName' ] }, + Id: 'StackMyDistOrigin1D6D5E535', + S3OriginConfig: { + OriginAccessIdentity: { 'Fn::Join': [ '', + [ 'origin-access-identity/cloudfront/', { Ref: 'MyDistS3Origin1ED86A27E' } ], + ]}, + }, + }], + }, + }); + }); + + test('a second behavior can be added to a secondary origin', () => { + const origin = Origin.fromBucket(new s3.Bucket(stack, 'Bucket')); + const origin2 = Origin.fromBucket(new s3.Bucket(stack, 'Bucket2')); + new Distribution(stack, 'MyDist', { + defaultBehavior: { origin }, + additionalBehaviors: { + 'api/*': { origin: origin2 }, + }, + }); + + expect(stack).toHaveResource('AWS::CloudFront::Distribution', { + DistributionConfig: { + DefaultCacheBehavior: { + ForwardedValues: { QueryString: false }, + TargetOriginId: 'StackMyDistOrigin1D6D5E535', + ViewerProtocolPolicy: 'allow-all', + }, + CacheBehaviors: [{ + PathPattern: 'api/*', + ForwardedValues: { QueryString: false }, + TargetOriginId: 'StackMyDistOrigin20B96F3AD', + ViewerProtocolPolicy: 'allow-all', + }], + Enabled: true, + Origins: [{ + DomainName: { 'Fn::GetAtt': [ 'Bucket83908E77', 'RegionalDomainName' ] }, + Id: 'StackMyDistOrigin1D6D5E535', + S3OriginConfig: { + OriginAccessIdentity: { 'Fn::Join': [ '', + [ 'origin-access-identity/cloudfront/', { Ref: 'MyDistS3Origin1ED86A27E' } ], + ]}, + }, + }, + { + DomainName: { 'Fn::GetAtt': [ 'Bucket25524B414', 'RegionalDomainName' ] }, + Id: 'StackMyDistOrigin20B96F3AD', + S3OriginConfig: { + OriginAccessIdentity: { 'Fn::Join': [ '', + [ 'origin-access-identity/cloudfront/', { Ref: 'MyDistS3Origin2E88F08BB' } ], + ]}, + }, + }], + }, + }); + }); + + test('behavior creation order is preserved', () => { + const origin = Origin.fromBucket(new s3.Bucket(stack, 'Bucket')); + const origin2 = Origin.fromBucket(new s3.Bucket(stack, 'Bucket2')); + const dist = new Distribution(stack, 'MyDist', { + defaultBehavior: { origin }, + additionalBehaviors: { + 'api/1*': { origin: origin2 }, + }, + }); + dist.addBehavior('api/2*', origin); + + expect(stack).toHaveResource('AWS::CloudFront::Distribution', { + DistributionConfig: { + DefaultCacheBehavior: { + ForwardedValues: { QueryString: false }, + TargetOriginId: 'StackMyDistOrigin1D6D5E535', + ViewerProtocolPolicy: 'allow-all', + }, + CacheBehaviors: [{ + PathPattern: 'api/1*', + ForwardedValues: { QueryString: false }, + TargetOriginId: 'StackMyDistOrigin20B96F3AD', + ViewerProtocolPolicy: 'allow-all', + }, + { + PathPattern: 'api/2*', + ForwardedValues: { QueryString: false }, + TargetOriginId: 'StackMyDistOrigin1D6D5E535', + ViewerProtocolPolicy: 'allow-all', + }], + Enabled: true, + Origins: [{ + DomainName: { 'Fn::GetAtt': [ 'Bucket83908E77', 'RegionalDomainName' ] }, + Id: 'StackMyDistOrigin1D6D5E535', + S3OriginConfig: { + OriginAccessIdentity: { 'Fn::Join': [ '', + [ 'origin-access-identity/cloudfront/', { Ref: 'MyDistS3Origin1ED86A27E' } ], + ]}, + }, + }, + { + DomainName: { 'Fn::GetAtt': [ 'Bucket25524B414', 'RegionalDomainName' ] }, + Id: 'StackMyDistOrigin20B96F3AD', + S3OriginConfig: { + OriginAccessIdentity: { 'Fn::Join': [ '', + [ 'origin-access-identity/cloudfront/', { Ref: 'MyDistS3Origin2E88F08BB' } ], + ]}, + }, + }], + }, + }); + }); + +}); + +describe('certificates', () => { + + test('should fail if using an imported certificate from outside of us-east-1', () => { + const origin = Origin.fromBucket(new s3.Bucket(stack, 'Bucket')); + const certificate = acm.Certificate.fromCertificateArn(stack, 'Cert', 'arn:aws:acm:eu-west-1:123456789012:certificate/12345678-1234-1234-1234-123456789012'); + + expect(() => { + new Distribution(stack, 'Dist', { + defaultBehavior: { origin }, + certificate, + }); + }).toThrow(/Distribution certificates must be in the us-east-1 region/); + }); + + test('adding a certificate renders the correct ViewerCertificate property', () => { + const certificate = acm.Certificate.fromCertificateArn(stack, 'Cert', 'arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012'); + + new Distribution(stack, 'Dist', { + defaultBehavior: { origin: Origin.fromBucket(new s3.Bucket(stack, 'Bucket')) }, + certificate, + }); + + expect(stack).toHaveResourceLike('AWS::CloudFront::Distribution', { + DistributionConfig: { + ViewerCertificate: { + AcmCertificateArn: 'arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012', + }, + }, + }); + }); +}); + +describe('custom error responses', () => { + + test('should fail if responsePagePath is defined but responseCode is not', () => { + const origin = Origin.fromBucket(new s3.Bucket(stack, 'Bucket')); + + expect(() => { + new Distribution(stack, 'Dist', { + defaultBehavior: { origin }, + errorResponses: [{ + httpStatus: 404, + responsePagePath: '/errors/404.html', + }], + }); + }).toThrow(/\'responseCode\' must be provided if \'responsePagePath\' is defined/); + }); + + test('should fail if only the error code is provided', () => { + const origin = Origin.fromBucket(new s3.Bucket(stack, 'Bucket')); + + expect(() => { + new Distribution(stack, 'Dist', { + defaultBehavior: { origin }, + errorResponses: [{ httpStatus: 404 }], + }); + }).toThrow(/A custom error response without either a \'responseCode\' or \'errorCachingMinTtl\' is not valid./); + }); + + test('should render the array of error configs if provided', () => { + const origin = Origin.fromBucket(new s3.Bucket(stack, 'Bucket')); + new Distribution(stack, 'Dist', { + defaultBehavior: { origin }, + errorResponses: [{ + httpStatus: 404, + responseHttpStatus: 404, + responsePagePath: '/errors/404.html', + }, + { + httpStatus: 500, + ttl: Duration.seconds(2), + }], + }); + + expect(stack).toHaveResourceLike('AWS::CloudFront::Distribution', { + DistributionConfig: { + CustomErrorResponses: [ + { + ErrorCode: 404, + ResponseCode: 404, + ResponsePagePath: '/errors/404.html', + }, + { + ErrorCachingMinTTL: 2, + ErrorCode: 500, + }, + ], + }, + }); + }); + +}); + +test('price class is included if provided', () => { + const origin = Origin.fromBucket(new s3.Bucket(stack, 'Bucket')); + new Distribution(stack, 'Dist', { + defaultBehavior: { origin }, + priceClass: PriceClass.PRICE_CLASS_200, + }); + + expect(stack).toHaveResourceLike('AWS::CloudFront::Distribution', { + DistributionConfig: { + PriceClass: 'PriceClass_200', + }, + }); + +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudfront/test/origin.test.ts b/packages/@aws-cdk/aws-cloudfront/test/origin.test.ts new file mode 100644 index 0000000000000..b02a10e6300db --- /dev/null +++ b/packages/@aws-cdk/aws-cloudfront/test/origin.test.ts @@ -0,0 +1,73 @@ +import '@aws-cdk/assert/jest'; +import * as s3 from '@aws-cdk/aws-s3'; +import { App, Stack } from '@aws-cdk/core'; +import { Distribution, Origin } from '../lib'; + +let app: App; +let stack: Stack; + +beforeEach(() => { + app = new App(); + stack = new Stack(app, 'Stack', { + env: { account: '1234', region: 'testregion' }, + }); +}); + +describe('fromBucket', () => { + + test('as bucket, renders all properties, including S3Origin config', () => { + const bucket = new s3.Bucket(stack, 'Bucket'); + + const origin = Origin.fromBucket(bucket); + origin._bind(stack, { originIndex: 0 }); + + expect(origin._renderOrigin()).toEqual({ + id: 'StackOrigin029E19582', + domainName: bucket.bucketRegionalDomainName, + s3OriginConfig: { + originAccessIdentity: 'origin-access-identity/cloudfront/${Token[TOKEN.69]}', + }, + }); + }); + + test('as bucket, creates an OriginAccessIdentity and grants read permissions on the bucket', () => { + const bucket = new s3.Bucket(stack, 'Bucket'); + + const origin = Origin.fromBucket(bucket); + new Distribution(stack, 'Dist', { defaultBehavior: { origin } }); + + expect(stack).toHaveResourceLike('AWS::CloudFront::CloudFrontOriginAccessIdentity', { + CloudFrontOriginAccessIdentityConfig: { + Comment: 'Allows CloudFront to reach the bucket', + }, + }); + expect(stack).toHaveResourceLike('AWS::S3::BucketPolicy', { + PolicyDocument: { + Statement: [{ + Principal: { + CanonicalUser: { 'Fn::GetAtt': [ 'DistS3Origin1C4519663', 'S3CanonicalUserId' ] }, + }, + }], + }, + }); + }); + + test('as website buvcket, renders all properties, including custom origin config', () => { + const bucket = new s3.Bucket(stack, 'Bucket', { + websiteIndexDocument: 'index.html', + }); + + const origin = Origin.fromBucket(bucket); + origin._bind(stack, { originIndex: 0 }); + + expect(origin._renderOrigin()).toEqual({ + id: 'StackOrigin029E19582', + domainName: bucket.bucketWebsiteDomainName, + customOriginConfig: { + originProtocolPolicy: 'http-only', + }, + }); + }); + +}); + diff --git a/packages/@aws-cdk/aws-cloudfront/test/private/cache-behavior.test.ts b/packages/@aws-cdk/aws-cloudfront/test/private/cache-behavior.test.ts new file mode 100644 index 0000000000000..b70d3ef02c590 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudfront/test/private/cache-behavior.test.ts @@ -0,0 +1,54 @@ +import '@aws-cdk/assert/jest'; +import * as s3 from '@aws-cdk/aws-s3'; +import { App, Stack } from '@aws-cdk/core'; +import { AllowedMethods, Origin } from '../../lib'; +import { CacheBehavior } from '../../lib/private/cache-behavior'; + +let app: App; +let stack: Stack; + +beforeEach(() => { + app = new App(); + stack = new Stack(app, 'Stack', { + env: { account: '1234', region: 'testregion' }, + }); +}); + +test('renders the minimum template with an origin and path specified', () => { + const origin = Origin.fromBucket(new s3.Bucket(stack, 'MyBucket')); + const behavior = new CacheBehavior({ + origin, + pathPattern: '*', + }); + origin._bind(stack, { originIndex: 0 }); + + expect(behavior._renderBehavior()).toEqual({ + targetOriginId: behavior.origin.id, + pathPattern: '*', + forwardedValues: { queryString: false }, + viewerProtocolPolicy: 'allow-all', + }); +}); + +test('renders with all properties specified', () => { + const origin = Origin.fromBucket(new s3.Bucket(stack, 'MyBucket')); + const behavior = new CacheBehavior({ + origin, + pathPattern: '*', + allowedMethods: AllowedMethods.ALLOW_ALL, + forwardQueryString: true, + forwardQueryStringCacheKeys: ['user_id', 'auth'], + }); + origin._bind(stack, { originIndex: 0 }); + + expect(behavior._renderBehavior()).toEqual({ + targetOriginId: behavior.origin.id, + pathPattern: '*', + allowedMethods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'PATCH', 'POST', 'DELETE'], + forwardedValues: { + queryString: true, + queryStringCacheKeys: ['user_id', 'auth'], + }, + viewerProtocolPolicy: 'allow-all', + }); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3/lib/bucket.ts b/packages/@aws-cdk/aws-s3/lib/bucket.ts index 03bf119e35574..9c49dde1be853 100644 --- a/packages/@aws-cdk/aws-s3/lib/bucket.ts +++ b/packages/@aws-cdk/aws-s3/lib/bucket.ts @@ -54,6 +54,11 @@ export interface IBucket extends IResource { */ readonly bucketRegionalDomainName: string; + /** + * If this bucket has been configured for static website hosting. + */ + readonly isWebsite?: boolean; + /** * Optional KMS encryption key associated with this bucket. */ @@ -279,6 +284,13 @@ export interface BucketAttributes { readonly bucketWebsiteNewUrlFormat?: boolean; readonly encryptionKey?: kms.IKey; + + /** + * If this bucket has been configured for static website hosting. + * + * @default false + */ + readonly isWebsite?: boolean; } /** @@ -312,6 +324,11 @@ abstract class BucketBase extends Resource implements IBucket { */ public abstract readonly encryptionKey?: kms.IKey; + /** + * If this bucket has been configured for static website hosting. + */ + public abstract readonly isWebsite?: boolean; + /** * The resource policy associated with this bucket. * @@ -1003,6 +1020,7 @@ export class Bucket extends BucketBase { public readonly bucketDualStackDomainName = attrs.bucketDualStackDomainName || `${bucketName}.s3.dualstack.${region}.${urlSuffix}`; public readonly bucketWebsiteNewUrlFormat = newUrlFormat; public readonly encryptionKey = attrs.encryptionKey; + public readonly isWebsite = attrs.isWebsite ?? false; public policy?: BucketPolicy = undefined; protected autoCreatePolicy = false; protected disallowPublicAccess = false; @@ -1027,6 +1045,7 @@ export class Bucket extends BucketBase { public readonly bucketRegionalDomainName: string; public readonly encryptionKey?: kms.IKey; + public readonly isWebsite?: boolean; public policy?: BucketPolicy; protected autoCreatePolicy = true; protected disallowPublicAccess?: boolean; @@ -1046,12 +1065,15 @@ export class Bucket extends BucketBase { this.validateBucketName(this.physicalName); + const websiteConfiguration = this.renderWebsiteConfiguration(props); + this.isWebsite = (websiteConfiguration !== undefined); + const resource = new CfnBucket(this, 'Resource', { bucketName: this.physicalName, bucketEncryption, versioningConfiguration: props.versioned ? { status: 'Enabled' } : undefined, lifecycleConfiguration: Lazy.anyValue({ produce: () => this.parseLifecycleConfiguration() }), - websiteConfiguration: this.renderWebsiteConfiguration(props), + websiteConfiguration, publicAccessBlockConfiguration: props.blockPublicAccess, metricsConfigurations: Lazy.anyValue({ produce: () => this.parseMetricConfiguration() }), corsConfiguration: Lazy.anyValue({ produce: () => this.parseCorsConfiguration() }), diff --git a/packages/@aws-cdk/aws-s3/test/test.bucket.ts b/packages/@aws-cdk/aws-s3/test/test.bucket.ts index 89bcd0cbdc426..3f177974fdbc1 100644 --- a/packages/@aws-cdk/aws-s3/test/test.bucket.ts +++ b/packages/@aws-cdk/aws-s3/test/test.bucket.ts @@ -1797,6 +1797,59 @@ export = { }, /The condition property cannot be an empty object/); test.done(); }, + 'isWebsite set properly with': { + 'only index doc'(test: Test) { + const stack = new cdk.Stack(); + const bucket = new s3.Bucket(stack, 'Website', { + websiteIndexDocument: 'index2.html', + }); + test.equal(bucket.isWebsite, true); + test.done(); + }, + 'error and index docs'(test: Test) { + const stack = new cdk.Stack(); + const bucket = new s3.Bucket(stack, 'Website', { + websiteIndexDocument: 'index2.html', + websiteErrorDocument: 'error.html', + }); + test.equal(bucket.isWebsite, true); + test.done(); + }, + 'redirects'(test: Test) { + const stack = new cdk.Stack(); + const bucket = new s3.Bucket(stack, 'Website', { + websiteRedirect: { + hostName: 'www.example.com', + protocol: s3.RedirectProtocol.HTTPS, + }, + }); + test.equal(bucket.isWebsite, true); + test.done(); + }, + 'no website properties set'(test: Test) { + const stack = new cdk.Stack(); + const bucket = new s3.Bucket(stack, 'Website'); + test.equal(bucket.isWebsite, false); + test.done(); + }, + 'imported website buckets'(test: Test) { + const stack = new cdk.Stack(); + const bucket = s3.Bucket.fromBucketAttributes(stack, 'Website', { + bucketArn: 'arn:aws:s3:::my-bucket', + isWebsite: true, + }); + test.equal(bucket.isWebsite, true); + test.done(); + }, + 'imported buckets'(test: Test) { + const stack = new cdk.Stack(); + const bucket = s3.Bucket.fromBucketAttributes(stack, 'NotWebsite', { + bucketArn: 'arn:aws:s3:::my-bucket', + }); + test.equal(bucket.isWebsite, false); + test.done(); + }, + }, }, 'Bucket.fromBucketArn'(test: Test) {