diff --git a/packages/@aws-cdk/aws-cloudfront-origins/README.md b/packages/@aws-cdk/aws-cloudfront-origins/README.md index b1dd1de905709..614a089247459 100644 --- a/packages/@aws-cdk/aws-cloudfront-origins/README.md +++ b/packages/@aws-cdk/aws-cloudfront-origins/README.md @@ -76,3 +76,24 @@ new cloudfront.Distribution(this, 'myDist', { ``` See the documentation of `@aws-cdk/aws-cloudfront` for more information. + +## Failover Origins (Origin Groups) + +You can set up CloudFront with origin failover for scenarios that require high availability. +To get started, you create an origin group with two origins: a primary and a secondary. +If the primary origin is unavailable, or returns specific HTTP response status codes that indicate a failure, +CloudFront automatically switches to the secondary origin. +You achieve that behavior in the CDK using the `OriginGroup` class: + +```ts +new cloudfront.Distribution(this, 'myDist', { + defaultBehavior: { + origin: new origins.OriginGroup({ + primaryOrigin: new origins.S3Origin(myBucket), + fallbackOrigin: new origins.HttpOrigin('www.example.com'), + // optional, defaults to: 500, 502, 503 and 504 + fallbackStatusCodes: [404], + }), + }, +}); +``` diff --git a/packages/@aws-cdk/aws-cloudfront-origins/lib/index.ts b/packages/@aws-cdk/aws-cloudfront-origins/lib/index.ts index 5f41b97f3dfde..6fc3bf1750637 100644 --- a/packages/@aws-cdk/aws-cloudfront-origins/lib/index.ts +++ b/packages/@aws-cdk/aws-cloudfront-origins/lib/index.ts @@ -1,3 +1,4 @@ export * from './http-origin'; export * from './load-balancer-origin'; export * from './s3-origin'; +export * from './origin-group'; diff --git a/packages/@aws-cdk/aws-cloudfront-origins/lib/origin-group.ts b/packages/@aws-cdk/aws-cloudfront-origins/lib/origin-group.ts new file mode 100644 index 0000000000000..8fa8284e62ea9 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudfront-origins/lib/origin-group.ts @@ -0,0 +1,49 @@ +import * as cloudfront from '@aws-cdk/aws-cloudfront'; +import { Construct } from '@aws-cdk/core'; + +/** Construction properties for {@link OriginGroup}. */ +export interface OriginGroupProps { + /** + * The primary origin that should serve requests for this group. + */ + readonly primaryOrigin: cloudfront.IOrigin; + + /** + * The fallback origin that should serve requests when the primary fails. + */ + readonly fallbackOrigin: cloudfront.IOrigin; + + /** + * The list of HTTP status codes that, + * when returned from the primary origin, + * would cause querying the fallback origin. + * + * @default - 500, 502, 503 and 504 + */ + readonly fallbackStatusCodes?: number[]; +} + +/** + * An Origin that represents a group. + * Consists of a primary Origin, + * and a fallback Origin called when the primary returns one of the provided HTTP status codes. + */ +export class OriginGroup implements cloudfront.IOrigin { + public constructor(private readonly props: OriginGroupProps) { + } + + public bind(scope: Construct, options: cloudfront.OriginBindOptions): cloudfront.OriginBindConfig { + const primaryOriginConfig = this.props.primaryOrigin.bind(scope, options); + if (primaryOriginConfig.failoverConfig) { + throw new Error('An OriginGroup cannot use an Origin with its own failover configuration as its primary origin!'); + } + + return { + originProperty: primaryOriginConfig.originProperty, + failoverConfig: { + failoverOrigin: this.props.fallbackOrigin, + statusCodes: this.props.fallbackStatusCodes, + }, + }; + } +} diff --git a/packages/@aws-cdk/aws-cloudfront-origins/lib/s3-origin.ts b/packages/@aws-cdk/aws-cloudfront-origins/lib/s3-origin.ts index 5c74b2b59382e..ea679a88a9aa7 100644 --- a/packages/@aws-cdk/aws-cloudfront-origins/lib/s3-origin.ts +++ b/packages/@aws-cdk/aws-cloudfront-origins/lib/s3-origin.ts @@ -42,7 +42,6 @@ export class S3Origin implements cloudfront.IOrigin { public bind(scope: cdk.Construct, options: cloudfront.OriginBindOptions): cloudfront.OriginBindConfig { return this.origin.bind(scope, options); } - } /** diff --git a/packages/@aws-cdk/aws-cloudfront-origins/test/integ.origin-group.expected.json b/packages/@aws-cdk/aws-cloudfront-origins/test/integ.origin-group.expected.json new file mode 100644 index 0000000000000..6ed3596c52eba --- /dev/null +++ b/packages/@aws-cdk/aws-cloudfront-origins/test/integ.origin-group.expected.json @@ -0,0 +1,144 @@ +{ + "Resources": { + "Bucket83908E77": { + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "BucketPolicyE9A3008A": { + "Type": "AWS::S3::BucketPolicy", + "Properties": { + "Bucket": { + "Ref": "Bucket83908E77" + }, + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*" + ], + "Effect": "Allow", + "Principal": { + "CanonicalUser": { + "Fn::GetAtt": [ + "DistributionOrigin1S3Origin5F5C0696", + "S3CanonicalUserId" + ] + } + }, + "Resource": [ + { + "Fn::GetAtt": [ + "Bucket83908E77", + "Arn" + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "Bucket83908E77", + "Arn" + ] + }, + "/*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + } + } + }, + "DistributionOrigin1S3Origin5F5C0696": { + "Type": "AWS::CloudFront::CloudFrontOriginAccessIdentity", + "Properties": { + "CloudFrontOriginAccessIdentityConfig": { + "Comment": "Allows CloudFront to reach the bucket" + } + } + }, + "DistributionCFDistribution882A7313": { + "Type": "AWS::CloudFront::Distribution", + "Properties": { + "DistributionConfig": { + "DefaultCacheBehavior": { + "ForwardedValues": { + "QueryString": false + }, + "TargetOriginId": "cloudfrontorigingroupDistributionOrigin137659A54", + "ViewerProtocolPolicy": "allow-all" + }, + "Enabled": true, + "OriginGroups": { + "Items": [ + { + "FailoverCriteria": { + "StatusCodes": { + "Items": [ + 500, + 502, + 503, + 504 + ], + "Quantity": 4 + } + }, + "Id": "cloudfrontorigingroupDistributionOriginGroup10B57F1D1", + "Members": { + "Items": [ + { + "OriginId": "cloudfrontorigingroupDistributionOrigin137659A54" + }, + { + "OriginId": "cloudfrontorigingroupDistributionOrigin2CCE5D500" + } + ], + "Quantity": 2 + } + } + ], + "Quantity": 1 + }, + "Origins": [ + { + "DomainName": { + "Fn::GetAtt": [ + "Bucket83908E77", + "RegionalDomainName" + ] + }, + "Id": "cloudfrontorigingroupDistributionOrigin137659A54", + "S3OriginConfig": { + "OriginAccessIdentity": { + "Fn::Join": [ + "", + [ + "origin-access-identity/cloudfront/", + { + "Ref": "DistributionOrigin1S3Origin5F5C0696" + } + ] + ] + } + } + }, + { + "CustomOriginConfig": { + "OriginProtocolPolicy": "https-only" + }, + "DomainName": "www.example.com", + "Id": "cloudfrontorigingroupDistributionOrigin2CCE5D500" + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudfront-origins/test/integ.origin-group.ts b/packages/@aws-cdk/aws-cloudfront-origins/test/integ.origin-group.ts new file mode 100644 index 0000000000000..31557317a9b40 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudfront-origins/test/integ.origin-group.ts @@ -0,0 +1,22 @@ +import * as cloudfront from '@aws-cdk/aws-cloudfront'; +import * as s3 from '@aws-cdk/aws-s3'; +import * as cdk from '@aws-cdk/core'; +import * as origins from '../lib'; + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'cloudfront-origin-group'); + +const bucket = new s3.Bucket(stack, 'Bucket', { + removalPolicy: cdk.RemovalPolicy.DESTROY, +}); + +const originGroup = new origins.OriginGroup({ + primaryOrigin: new origins.S3Origin(bucket), + fallbackOrigin: new origins.HttpOrigin('www.example.com'), +}); + +new cloudfront.Distribution(stack, 'Distribution', { + defaultBehavior: { origin: originGroup }, +}); + +app.synth(); diff --git a/packages/@aws-cdk/aws-cloudfront-origins/test/origin-group.test.ts b/packages/@aws-cdk/aws-cloudfront-origins/test/origin-group.test.ts new file mode 100644 index 0000000000000..00dce3cc8c39e --- /dev/null +++ b/packages/@aws-cdk/aws-cloudfront-origins/test/origin-group.test.ts @@ -0,0 +1,142 @@ +import '@aws-cdk/assert/jest'; +import * as cloudfront from '@aws-cdk/aws-cloudfront'; +import * as s3 from '@aws-cdk/aws-s3'; +import { Stack } from '@aws-cdk/core'; +import * as origins from '../lib'; + +let stack: Stack; +let bucket: s3.IBucket; +let primaryOrigin: cloudfront.IOrigin; +beforeEach(() => { + stack = new Stack(); + bucket = new s3.Bucket(stack, 'Bucket'); + primaryOrigin = new origins.S3Origin(bucket); +}); + +describe('Origin Groups', () => { + test('correctly render the OriginGroups property of DistributionConfig', () => { + const failoverOrigin = new origins.S3Origin(s3.Bucket.fromBucketName(stack, 'ImportedBucket', 'imported-bucket')); + const originGroup = new origins.OriginGroup({ + primaryOrigin, + fallbackOrigin: failoverOrigin, + fallbackStatusCodes: [500], + }); + + new cloudfront.Distribution(stack, 'Distribution', { + defaultBehavior: { origin: originGroup }, + }); + + const primaryOriginId = 'DistributionOrigin13547B94F'; + const failoverOriginId = 'DistributionOrigin2C85CC43B'; + expect(stack).toHaveResourceLike('AWS::CloudFront::Distribution', { + DistributionConfig: { + Origins: [ + { + Id: primaryOriginId, + DomainName: { + 'Fn::GetAtt': ['Bucket83908E77', 'RegionalDomainName'], + }, + S3OriginConfig: { + OriginAccessIdentity: { + 'Fn::Join': ['', [ + 'origin-access-identity/cloudfront/', + { Ref: 'DistributionOrigin1S3Origin5F5C0696' }, + ]], + }, + }, + }, + { + Id: failoverOriginId, + DomainName: { + 'Fn::Join': ['', [ + 'imported-bucket.s3.', + { Ref: 'AWS::Region' }, + '.', + { Ref: 'AWS::URLSuffix' }, + ]], + }, + S3OriginConfig: { + OriginAccessIdentity: { + 'Fn::Join': ['', [ + 'origin-access-identity/cloudfront/', + { Ref: 'DistributionOrigin2S3OriginE484D4BF' }, + ]], + }, + }, + }, + ], + OriginGroups: { + Items: [ + { + FailoverCriteria: { + StatusCodes: { + Items: [500], + Quantity: 1, + }, + }, + Id: 'DistributionOriginGroup1A1A31B49', + Members: { + Items: [ + { OriginId: primaryOriginId }, + { OriginId: failoverOriginId }, + ], + Quantity: 2, + }, + }, + ], + Quantity: 1, + }, + }, + }); + }); + + test('cannot have an Origin with their own failover configuration as the primary Origin', () => { + const failoverOrigin = new origins.S3Origin(s3.Bucket.fromBucketName(stack, 'ImportedBucket', 'imported-bucket')); + const originGroup = new origins.OriginGroup({ + primaryOrigin, + fallbackOrigin: failoverOrigin, + }); + const groupOfGroups = new origins.OriginGroup({ + primaryOrigin: originGroup, + fallbackOrigin: failoverOrigin, + }); + + expect(() => { + new cloudfront.Distribution(stack, 'Distribution', { + defaultBehavior: { origin: groupOfGroups }, + }); + }).toThrow(/An OriginGroup cannot use an Origin with its own failover configuration as its primary origin!/); + }); + + test('cannot have an Origin with their own failover configuration as the fallback Origin', () => { + const originGroup = new origins.OriginGroup({ + primaryOrigin, + fallbackOrigin: new origins.S3Origin(s3.Bucket.fromBucketName(stack, 'ImportedBucket', 'imported-bucket')), + }); + const groupOfGroups = new origins.OriginGroup({ + primaryOrigin, + fallbackOrigin: originGroup, + }); + + expect(() => { + new cloudfront.Distribution(stack, 'Distribution', { + defaultBehavior: { origin: groupOfGroups }, + }); + }).toThrow(/An Origin cannot use an Origin with its own failover configuration as its fallback origin!/); + }); + + test('cannot have an empty array of fallbackStatusCodes', () => { + const failoverOrigin = new origins.S3Origin(s3.Bucket.fromBucketName(stack, 'ImportedBucket', 'imported-bucket')); + const originGroup = new origins.OriginGroup({ + primaryOrigin, + fallbackOrigin: failoverOrigin, + fallbackStatusCodes: [], + }); + + expect(() => { + new cloudfront.Distribution(stack, 'Distribution', { + defaultBehavior: { origin: originGroup }, + }); + }).toThrow(/fallbackStatusCodes cannot be empty/); + }); +}); diff --git a/packages/@aws-cdk/aws-cloudfront/lib/distribution.ts b/packages/@aws-cdk/aws-cloudfront/lib/distribution.ts index 19516b8a59ae8..157f0c7535229 100644 --- a/packages/@aws-cdk/aws-cloudfront/lib/distribution.ts +++ b/packages/@aws-cdk/aws-cloudfront/lib/distribution.ts @@ -132,6 +132,7 @@ export class Distribution extends Resource implements IDistribution { private readonly defaultBehavior: CacheBehavior; private readonly additionalBehaviors: CacheBehavior[] = []; private readonly boundOrigins: BoundOrigin[] = []; + private readonly originGroups: CfnDistribution.OriginGroupProperty[] = []; private readonly errorResponses: ErrorResponse[]; private readonly certificate?: acm.ICertificate; @@ -160,6 +161,7 @@ export class Distribution extends Resource implements IDistribution { const distribution = new CfnDistribution(this, 'CFDistribution', { distributionConfig: { enabled: true, origins: Lazy.anyValue({ produce: () => this.renderOrigins() }), + originGroups: Lazy.anyValue({ produce: () => this.renderOriginGroups() }), defaultCacheBehavior: this.defaultBehavior._renderBehavior(), cacheBehaviors: Lazy.anyValue({ produce: () => this.renderCacheBehaviors() }), viewerCertificate: this.certificate ? this.renderViewerCertificate(this.certificate) : undefined, @@ -187,7 +189,7 @@ export class Distribution extends Resource implements IDistribution { this.additionalBehaviors.push(new CacheBehavior(originId, { pathPattern, ...behaviorOptions })); } - private addOrigin(origin: IOrigin): string { + private addOrigin(origin: IOrigin, isFailoverOrigin: boolean = false): string { const existingOrigin = this.boundOrigins.find(boundOrigin => boundOrigin.origin === origin); if (existingOrigin) { return existingOrigin.originId; @@ -197,10 +199,41 @@ export class Distribution extends Resource implements IDistribution { const originId = scope.node.uniqueId; const originBindConfig = origin.bind(scope, { originId }); this.boundOrigins.push({ origin, originId, ...originBindConfig }); + if (originBindConfig.failoverConfig) { + if (isFailoverOrigin) { + throw new Error('An Origin cannot use an Origin with its own failover configuration as its fallback origin!'); + } + const failoverOriginId = this.addOrigin(originBindConfig.failoverConfig.failoverOrigin, true); + this.addOriginGroup(originBindConfig.failoverConfig.statusCodes, originId, failoverOriginId); + } return originId; } } + private addOriginGroup(statusCodes: number[] | undefined, originId: string, failoverOriginId: string): void { + statusCodes = statusCodes ?? [500, 502, 503, 504]; + if (statusCodes.length === 0) { + throw new Error('fallbackStatusCodes cannot be empty'); + } + const groupIndex = this.originGroups.length + 1; + this.originGroups.push({ + failoverCriteria: { + statusCodes: { + items: statusCodes, + quantity: statusCodes.length, + }, + }, + id: new Construct(this, `OriginGroup${groupIndex}`).node.uniqueId, + members: { + items: [ + { originId }, + { originId: failoverOriginId }, + ], + quantity: 2, + }, + }); + } + private renderOrigins(): CfnDistribution.OriginProperty[] { const renderedOrigins: CfnDistribution.OriginProperty[] = []; this.boundOrigins.forEach(boundOrigin => { @@ -211,6 +244,15 @@ export class Distribution extends Resource implements IDistribution { return renderedOrigins; } + private renderOriginGroups(): CfnDistribution.OriginGroupsProperty | undefined { + return this.originGroups.length === 0 + ? undefined + : { + items: this.originGroups, + quantity: this.originGroups.length, + }; + } + private renderCacheBehaviors(): CfnDistribution.CacheBehaviorProperty[] | undefined { if (this.additionalBehaviors.length === 0) { return undefined; } return this.additionalBehaviors.map(behavior => behavior._renderBehavior()); diff --git a/packages/@aws-cdk/aws-cloudfront/lib/origin.ts b/packages/@aws-cdk/aws-cloudfront/lib/origin.ts index 411212575b07d..cee40aa4e149b 100644 --- a/packages/@aws-cdk/aws-cloudfront/lib/origin.ts +++ b/packages/@aws-cdk/aws-cloudfront/lib/origin.ts @@ -1,6 +1,22 @@ import { Construct, Duration, Token } from '@aws-cdk/core'; import { CfnDistribution } from './cloudfront.generated'; +/** + * The failover configuration used for Origin Groups, + * returned in {@link OriginBindConfig.failoverConfig}. + */ +export interface OriginFailoverConfig { + /** The origin to use as the fallback origin. */ + readonly failoverOrigin: IOrigin; + + /** + * The HTTP status codes of the response that trigger querying the failover Origin. + * + * @default - 500, 502, 503 and 504 + */ + readonly statusCodes?: number[]; +} + /** The struct returned from {@link IOrigin.bind}. */ export interface OriginBindConfig { /** @@ -9,6 +25,13 @@ export interface OriginBindConfig { * @default - nothing is returned */ readonly originProperty?: CfnDistribution.OriginProperty; + + /** + * The failover configuration for this Origin. + * + * @default - nothing is returned + */ + readonly failoverConfig?: OriginFailoverConfig; } /**