Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

server-side-website with additional S3 Origin behaviour #325

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
Open
39 changes: 39 additions & 0 deletions docs/server-side-website.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,45 @@ Applications are of course free to catch errors and display custom error pages.

All headers are forwarded to your Lambda backend. If you used the `forwardedHeaders` option in the past, you can safely remove it.

### Routing paths to other S3 buckets

You can route any paths to alternative S3 buckets like you would with a local directory. To do that, prefix the S3 bucket name with `s3://`.

```yaml
constructs:
website:
# ...
assets:
'/assets/*': dist/
'/uploads/*': s3://another-bucket-name/optional-path
```

Your bucket will need to either need an origin access policy assigned (recommended), or be set to public. CDK will automatically create an access policy, but it won't be added to your S3 bucket automatically. You'll need to update the bucket policy using a template like this:
```json
{
"Version": "2012-10-17",
"Statement": {
"Sid": "AllowCloudFrontServicePrincipalReadOnly",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::<S3 bucket name>/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::<AWS account ID>:distribution/<CloudFront distribution ID>"
}
}
}
}
```

More details can be found here:
https://docs.aws.amazon.com/whitepapers/latest/secure-content-delivery-amazon-cloudfront/s3-origin-with-cloudfront.html

**Note**: This won't work with S3 buckets using KMS. S3 SSE is okay though.

## Extensions

You can specify an `extensions` property on the `server-side-website` construct to extend the underlying CloudFormation resources. In the exemple below, the CloudFront Distribution CloudFormation resource generated by the `website` server-side-website construct will be extended with the new `Comment: Landing distribution` CloudFormation property.
Expand Down
42 changes: 38 additions & 4 deletions src/constructs/aws/ServerSideWebsite.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CfnBucket } from "aws-cdk-lib/aws-s3";
import type { CfnBucket, IBucket } from "aws-cdk-lib/aws-s3";
import { Bucket } from "aws-cdk-lib/aws-s3";
import type { CfnDistribution, IOriginRequestPolicy } from "aws-cdk-lib/aws-cloudfront";
import {
Expand Down Expand Up @@ -244,6 +244,11 @@ export class ServerSideWebsite extends AwsConstruct {

let invalidate = false;
for (const [pattern, filePath] of Object.entries(this.getAssetPatterns())) {
// Ignore external buckets
if (filePath.startsWith("s3://")) {
continue;
}

if (!fs.existsSync(filePath)) {
throw new ServerlessError(
`Error in 'constructs.${this.id}': the file or directory '${filePath}' does not exist`,
Expand Down Expand Up @@ -352,20 +357,49 @@ export class ServerSideWebsite extends AwsConstruct {

private createCacheBehaviors(bucket: Bucket): Record<string, BehaviorOptions> {
const behaviors: Record<string, BehaviorOptions> = {};
for (const pattern of Object.keys(this.getAssetPatterns())) {
const patterns = this.getAssetPatterns();
const customOrigins: Record<string, IBucket> = {};

for (const pattern in patterns) {
if (pattern === "/" || pattern === "/*") {
throw new ServerlessError(
`Invalid key in 'constructs.${this.id}.assets': '/' and '/*' cannot be routed to assets because the root URL already serves the backend application running in Lambda. You must use a sub-path instead, for example '/assets/*'.`,
"LIFT_INVALID_CONSTRUCT_CONFIGURATION"
);
}

let originBucket = new S3Origin(bucket);
if (patterns[pattern].startsWith("s3://")) {
let existingBucketName = patterns[pattern].substring(5);
const originProperties = {
originPath: "/",
cachePolicy: CachePolicy.CACHING_DISABLED,
};

// Support mapping to custom origin paths
if (existingBucketName.indexOf("/") !== -1) {
originProperties.originPath = existingBucketName.substring(existingBucketName.indexOf("/") + 1);
existingBucketName = existingBucketName.split("/", 1)[0];
}

let existingBucket;
if (existingBucketName in customOrigins) {
existingBucket = customOrigins[existingBucketName];
} else {
existingBucket = Bucket.fromBucketName(this, existingBucketName, existingBucketName);
customOrigins[existingBucketName] = existingBucket;
}

originBucket = new S3Origin(existingBucket, originProperties);
}

behaviors[pattern] = {
// Origins are where CloudFront fetches content
origin: new S3Origin(bucket),
origin: originBucket,
allowedMethods: AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
// Use the "Managed-CachingOptimized" policy
// See https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html#managed-cache-policies-list
cachePolicy: CachePolicy.CACHING_OPTIMIZED,
cachePolicy: CachePolicy.CACHING_DISABLED,
viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
};
}
Expand Down
130 changes: 125 additions & 5 deletions test/unit/serverSideWebsite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,41 @@ describe("server-side website", () => {
type: "server-side-website",
assets: {
"/assets/*": "public",
"/s3-bucket/*": "s3://some-bucket",
"/s3-bucket-with-path/*": "s3://some-other-bucket/some-path",
"/s3-bucket-repeat/a/*": "s3://some-bucket-repeat",
"/s3-bucket-repeat/b/*": "s3://some-bucket-repeat",
},
},
},
}),
});
const bucketLogicalId = computeLogicalId("backend", "Assets");
const bucketPolicyLogicalId = computeLogicalId("backend", "Assets", "Policy");
const originAccessIdentityLogicalId = computeLogicalId("backend", "CDN", "Origin2", "S3Origin");
const originAccessIdentityLogicalId1 = computeLogicalId("backend", "CDN", "Origin2", "S3Origin");
const originAccessIdentityLogicalId2 = computeLogicalId("backend", "CDN", "Origin3", "S3Origin");
const originAccessIdentityLogicalId3 = computeLogicalId("backend", "CDN", "Origin4", "S3Origin");
const originAccessIdentityLogicalId4 = computeLogicalId("backend", "CDN", "Origin5", "S3Origin");
const originAccessIdentityLogicalId5 = computeLogicalId("backend", "CDN", "Origin6", "S3Origin");
const cfDistributionLogicalId = computeLogicalId("backend", "CDN");
const cfOriginId1 = computeLogicalId("backend", "CDN", "Origin1");
const cfOriginId2 = computeLogicalId("backend", "CDN", "Origin2");
const cfOriginId3 = computeLogicalId("backend", "CDN", "Origin3");
const cfOriginId4 = computeLogicalId("backend", "CDN", "Origin4");
const cfOriginId5 = computeLogicalId("backend", "CDN", "Origin5");
const cfOriginId6 = computeLogicalId("backend", "CDN", "Origin6");
const requestFunction = computeLogicalId("backend", "RequestFunction");
expect(Object.keys(cfTemplate.Resources)).toStrictEqual([
"ServerlessDeploymentBucket",
"ServerlessDeploymentBucketPolicy",
bucketLogicalId,
bucketPolicyLogicalId,
requestFunction,
originAccessIdentityLogicalId,
originAccessIdentityLogicalId1,
originAccessIdentityLogicalId2,
originAccessIdentityLogicalId3,
originAccessIdentityLogicalId4,
originAccessIdentityLogicalId5,
cfDistributionLogicalId,
]);
expect(cfTemplate.Resources[bucketLogicalId]).toMatchObject({
Expand All @@ -55,7 +71,7 @@ describe("server-side website", () => {
Action: "s3:GetObject",
Effect: "Allow",
Principal: {
CanonicalUser: { "Fn::GetAtt": [originAccessIdentityLogicalId, "S3CanonicalUserId"] },
CanonicalUser: { "Fn::GetAtt": [originAccessIdentityLogicalId1, "S3CanonicalUserId"] },
},
Resource: { "Fn::Join": ["", [{ "Fn::GetAtt": [bucketLogicalId, "Arn"] }, "/*"]] },
},
Expand All @@ -64,7 +80,7 @@ describe("server-side website", () => {
},
},
});
expect(cfTemplate.Resources[originAccessIdentityLogicalId]).toStrictEqual({
expect(cfTemplate.Resources[originAccessIdentityLogicalId1]).toStrictEqual({
Type: "AWS::CloudFront::CloudFrontOriginAccessIdentity",
Properties: {
CloudFrontOriginAccessIdentityConfig: {
Expand Down Expand Up @@ -106,6 +122,38 @@ describe("server-side website", () => {
TargetOriginId: cfOriginId2,
ViewerProtocolPolicy: "redirect-to-https",
},
{
AllowedMethods: ["GET", "HEAD", "OPTIONS"],
CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6",
Compress: true,
PathPattern: "/s3-bucket/*",
TargetOriginId: cfOriginId3,
ViewerProtocolPolicy: "redirect-to-https",
},
{
AllowedMethods: ["GET", "HEAD", "OPTIONS"],
CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6",
Compress: true,
PathPattern: "/s3-bucket-with-path/*",
TargetOriginId: cfOriginId4,
ViewerProtocolPolicy: "redirect-to-https",
},
{
AllowedMethods: ["GET", "HEAD", "OPTIONS"],
CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6",
Compress: true,
PathPattern: "/s3-bucket-repeat/a/*",
TargetOriginId: cfOriginId5,
ViewerProtocolPolicy: "redirect-to-https",
},
{
AllowedMethods: ["GET", "HEAD", "OPTIONS"],
CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6",
Compress: true,
PathPattern: "/s3-bucket-repeat/b/*",
TargetOriginId: cfOriginId6,
ViewerProtocolPolicy: "redirect-to-https",
},
],
Enabled: true,
HttpVersion: "http2and3",
Expand All @@ -128,7 +176,79 @@ describe("server-side website", () => {
OriginAccessIdentity: {
"Fn::Join": [
"",
["origin-access-identity/cloudfront/", { Ref: originAccessIdentityLogicalId }],
["origin-access-identity/cloudfront/", { Ref: originAccessIdentityLogicalId1 }],
],
},
},
},
{
DomainName: {
"Fn::Join": [
"",
["some-bucket.s3.", { Ref: "AWS::Region" }, ".", { Ref: "AWS::URLSuffix" }],
],
},
Id: cfOriginId3,
OriginPath: "",
S3OriginConfig: {
OriginAccessIdentity: {
"Fn::Join": [
"",
["origin-access-identity/cloudfront/", { Ref: originAccessIdentityLogicalId2 }],
],
},
},
},
{
DomainName: {
"Fn::Join": [
"",
["some-other-bucket.s3.", { Ref: "AWS::Region" }, ".", { Ref: "AWS::URLSuffix" }],
],
},
Id: cfOriginId4,
OriginPath: "/some-path",
S3OriginConfig: {
OriginAccessIdentity: {
"Fn::Join": [
"",
["origin-access-identity/cloudfront/", { Ref: originAccessIdentityLogicalId3 }],
],
},
},
},
{
DomainName: {
"Fn::Join": [
"",
["some-bucket-repeat.s3.", { Ref: "AWS::Region" }, ".", { Ref: "AWS::URLSuffix" }],
],
},
Id: cfOriginId5,
OriginPath: "",
S3OriginConfig: {
OriginAccessIdentity: {
"Fn::Join": [
"",
["origin-access-identity/cloudfront/", { Ref: originAccessIdentityLogicalId4 }],
],
},
},
},
{
DomainName: {
"Fn::Join": [
"",
["some-bucket-repeat.s3.", { Ref: "AWS::Region" }, ".", { Ref: "AWS::URLSuffix" }],
],
},
Id: cfOriginId6,
OriginPath: "",
S3OriginConfig: {
OriginAccessIdentity: {
"Fn::Join": [
"",
["origin-access-identity/cloudfront/", { Ref: originAccessIdentityLogicalId5 }],
],
},
},
Expand Down
Loading