Skip to content

Commit e84bdd6

Browse files
hoegertnElad Ben-Israel
authored andcommitted
feat(s3-deployment): CloudFront invalidation (#3213)
see #3106
1 parent 56656e0 commit e84bdd6

File tree

10 files changed

+629
-3
lines changed

10 files changed

+629
-3
lines changed

packages/@aws-cdk/aws-cloudfront/lib/distribution.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,9 @@ export interface IDistribution {
66
* The domain name of the distribution
77
*/
88
readonly domainName: string;
9+
10+
/**
11+
* The distribution ID for this distribution.
12+
*/
13+
readonly distributionId: string;
914
}

packages/@aws-cdk/aws-s3-deployment/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,32 @@ By default, the contents of the destination bucket will be deleted when the
6363
changed. You can use the option `retainOnDelete: true` to disable this behavior,
6464
in which case the contents will be retained.
6565

66+
## CloudFront Invalidation
67+
68+
You can provide a CloudFront distribution and optional paths to invalidate after the bucket deployment finishes.
69+
70+
```ts
71+
const bucket = new s3.Bucket(this, 'Destination');
72+
73+
const distribution = new cloudfront.CloudFrontWebDistribution(this, 'Distribution', {
74+
originConfigs: [
75+
{
76+
s3OriginSource: {
77+
s3BucketSource: bucket
78+
},
79+
behaviors : [ {isDefaultBehavior: true}]
80+
}
81+
]
82+
});
83+
84+
new s3deploy.BucketDeployment(this, 'DeployWithInvalidation', {
85+
source: s3deploy.Source.asset('./website-dist'),
86+
destinationBucket: bucket,
87+
distribution,
88+
distributionPaths: ['/images/*.png'],
89+
});
90+
```
91+
6692
## Notes
6793

6894
* This library uses an AWS CloudFormation custom resource which about 10MiB in

packages/@aws-cdk/aws-s3-deployment/lambda/src/index.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import traceback
77
import logging
88
import shutil
9+
import boto3
10+
from datetime import datetime
911
from uuid import uuid4
1012

1113
from botocore.vendored import requests
@@ -14,6 +16,8 @@
1416
logger = logging.getLogger()
1517
logger.setLevel(logging.INFO)
1618

19+
cloudfront = boto3.client('cloudfront')
20+
1721
CFN_SUCCESS = "SUCCESS"
1822
CFN_FAILED = "FAILED"
1923

@@ -40,6 +44,16 @@ def cfn_error(message=None):
4044
dest_bucket_name = props['DestinationBucketName']
4145
dest_bucket_prefix = props.get('DestinationBucketKeyPrefix', '')
4246
retain_on_delete = props.get('RetainOnDelete', "true") == "true"
47+
distribution_id = props.get('DistributionId', '')
48+
49+
default_distribution_path = dest_bucket_prefix
50+
if not default_distribution_path.endswith("/"):
51+
default_distribution_path += "/"
52+
if not default_distribution_path.startswith("/"):
53+
default_distribution_path = "/" + default_distribution_path
54+
default_distribution_path += "*"
55+
56+
distribution_paths = props.get('DistributionPaths', [default_distribution_path])
4357
except KeyError as e:
4458
cfn_error("missing request resource property %s. props: %s" % (str(e), props))
4559
return
@@ -84,6 +98,9 @@ def cfn_error(message=None):
8498
if request_type == "Update" or request_type == "Create":
8599
s3_deploy(s3_source_zip, s3_dest)
86100

101+
if distribution_id:
102+
cloudfront_invalidate(distribution_id, distribution_paths, physical_id)
103+
87104
cfn_send(event, context, CFN_SUCCESS, physicalResourceId=physical_id)
88105
except KeyError as e:
89106
cfn_error("invalid request. Missing key %s" % str(e))
@@ -114,6 +131,23 @@ def s3_deploy(s3_source_zip, s3_dest):
114131
aws_command("s3", "sync", "--delete", contents_dir, s3_dest)
115132
shutil.rmtree(workdir)
116133

134+
#---------------------------------------------------------------------------------------------------
135+
# invalidate files in the CloudFront distribution edge caches
136+
def cloudfront_invalidate(distribution_id, distribution_paths, physical_id):
137+
invalidation_resp = cloudfront.create_invalidation(
138+
DistributionId=distribution_id,
139+
InvalidationBatch={
140+
'Paths': {
141+
'Quantity': len(distribution_paths),
142+
'Items': distribution_paths
143+
},
144+
'CallerReference': physical_id,
145+
})
146+
# by default, will wait up to 10 minutes
147+
cloudfront.get_waiter('invalidation_completed').wait(
148+
DistributionId=distribution_id,
149+
Id=invalidation_resp['Invalidation']['Id'])
150+
117151
#---------------------------------------------------------------------------------------------------
118152
# executes an "aws" cli command
119153
def aws_command(*args):
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
awscli==1.16.34
2-
2+
boto3==1.9.177

packages/@aws-cdk/aws-s3-deployment/lambda/test/test.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@
66
import sys
77
import traceback
88
import logging
9+
import botocore
910
from botocore.vendored import requests
11+
from botocore.exceptions import ClientError
1012
from unittest.mock import MagicMock
13+
from unittest.mock import patch
14+
1115

1216
class TestHandler(unittest.TestCase):
1317
def setUp(self):
1418
logger = logging.getLogger()
15-
19+
1620
# clean up old aws.out file (from previous runs)
1721
try: os.remove("aws.out")
1822
except OSError: pass
@@ -133,6 +137,75 @@ def test_update_same_dest(self):
133137
"s3 sync --delete contents.zip s3://<dest-bucket-name>/"
134138
)
135139

140+
def test_update_same_dest_cf_invalidate(self):
141+
def mock_make_api_call(self, operation_name, kwarg):
142+
if operation_name == 'CreateInvalidation':
143+
assert kwarg['DistributionId'] == '<cf-dist-id>'
144+
assert kwarg['InvalidationBatch']['Paths']['Quantity'] == 1
145+
assert kwarg['InvalidationBatch']['Paths']['Items'][0] == '/*'
146+
assert kwarg['InvalidationBatch']['CallerReference'] == '<physical-id>'
147+
return {'Invalidation': {'Id': '<invalidation-id>'}}
148+
if operation_name == 'GetInvalidation' and kwarg['Id'] == '<invalidation-id>':
149+
return {'Invalidation': {'Id': '<invalidation-id>', 'Status': 'Completed'}}
150+
raise ClientError({'Error': {'Code': '500', 'Message': 'Unsupported operation'}}, operation_name)
151+
152+
with patch('botocore.client.BaseClient._make_api_call', new=mock_make_api_call):
153+
invoke_handler("Update", {
154+
"SourceBucketName": "<source-bucket>",
155+
"SourceObjectKey": "<source-object-key>",
156+
"DestinationBucketName": "<dest-bucket-name>",
157+
"DistributionId": "<cf-dist-id>"
158+
}, old_resource_props={
159+
"DestinationBucketName": "<dest-bucket-name>",
160+
}, physical_id="<physical-id>")
161+
162+
def test_update_same_dest_cf_invalidate_custom_prefix(self):
163+
def mock_make_api_call(self, operation_name, kwarg):
164+
if operation_name == 'CreateInvalidation':
165+
assert kwarg['DistributionId'] == '<cf-dist-id>'
166+
assert kwarg['InvalidationBatch']['Paths']['Quantity'] == 1
167+
assert kwarg['InvalidationBatch']['Paths']['Items'][0] == '/<dest-prefix>/*'
168+
assert kwarg['InvalidationBatch']['CallerReference'] == '<physical-id>'
169+
return {'Invalidation': {'Id': '<invalidation-id>'}}
170+
if operation_name == 'GetInvalidation' and kwarg['Id'] == '<invalidation-id>':
171+
return {'Invalidation': {'Id': '<invalidation-id>', 'Status': 'Completed'}}
172+
raise ClientError({'Error': {'Code': '500', 'Message': 'Unsupported operation'}}, operation_name)
173+
174+
with patch('botocore.client.BaseClient._make_api_call', new=mock_make_api_call):
175+
invoke_handler("Update", {
176+
"SourceBucketName": "<source-bucket>",
177+
"SourceObjectKey": "<source-object-key>",
178+
"DestinationBucketName": "<dest-bucket-name>",
179+
"DestinationBucketKeyPrefix": "<dest-prefix>",
180+
"DistributionId": "<cf-dist-id>"
181+
}, old_resource_props={
182+
"DestinationBucketName": "<dest-bucket-name>",
183+
}, physical_id="<physical-id>")
184+
185+
def test_update_same_dest_cf_invalidate_custom_paths(self):
186+
def mock_make_api_call(self, operation_name, kwarg):
187+
if operation_name == 'CreateInvalidation':
188+
assert kwarg['DistributionId'] == '<cf-dist-id>'
189+
assert kwarg['InvalidationBatch']['Paths']['Quantity'] == 2
190+
assert kwarg['InvalidationBatch']['Paths']['Items'][0] == '/path1/*'
191+
assert kwarg['InvalidationBatch']['Paths']['Items'][1] == '/path2/*'
192+
assert kwarg['InvalidationBatch']['CallerReference'] == '<physical-id>'
193+
return {'Invalidation': {'Id': '<invalidation-id>'}}
194+
if operation_name == 'GetInvalidation' and kwarg['Id'] == '<invalidation-id>':
195+
return {'Invalidation': {'Id': '<invalidation-id>', 'Status': 'Completed'}}
196+
raise ClientError({'Error': {'Code': '500', 'Message': 'Unsupported operation'}}, operation_name)
197+
198+
with patch('botocore.client.BaseClient._make_api_call', new=mock_make_api_call):
199+
invoke_handler("Update", {
200+
"SourceBucketName": "<source-bucket>",
201+
"SourceObjectKey": "<source-object-key>",
202+
"DestinationBucketName": "<dest-bucket-name>",
203+
"DistributionId": "<cf-dist-id>",
204+
"DistributionPaths": ["/path1/*", "/path2/*"]
205+
}, old_resource_props={
206+
"DestinationBucketName": "<dest-bucket-name>",
207+
}, physical_id="<physical-id>")
208+
136209
def test_update_new_dest_retain(self):
137210
invoke_handler("Update", {
138211
"SourceBucketName": "<source-bucket>",

packages/@aws-cdk/aws-s3-deployment/lib/bucket-deployment.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import cloudformation = require('@aws-cdk/aws-cloudformation');
2+
import cloudfront = require('@aws-cdk/aws-cloudfront');
3+
import iam = require('@aws-cdk/aws-iam');
24
import lambda = require('@aws-cdk/aws-lambda');
35
import s3 = require('@aws-cdk/aws-s3');
46
import cdk = require('@aws-cdk/core');
@@ -37,12 +39,32 @@ export interface BucketDeploymentProps {
3739
* @default true - when resource is deleted/updated, files are retained
3840
*/
3941
readonly retainOnDelete?: boolean;
42+
43+
/**
44+
* The CloudFront distribution using the destination bucket as an origin.
45+
* Files in the distribution's edge caches will be invalidated after
46+
* files are uploaded to the destination bucket.
47+
*
48+
* @default - No invalidation occurs
49+
*/
50+
readonly distribution?: cloudfront.IDistribution;
51+
52+
/**
53+
* The file paths to invalidate in the CloudFront distribution.
54+
*
55+
* @default - All files under the destination bucket key prefix will be invalidated.
56+
*/
57+
readonly distributionPaths?: string[];
4058
}
4159

4260
export class BucketDeployment extends cdk.Construct {
4361
constructor(scope: cdk.Construct, id: string, props: BucketDeploymentProps) {
4462
super(scope, id);
4563

64+
if (props.distributionPaths && !props.distribution) {
65+
throw new Error("Distribution must be specified if distribution paths are specified");
66+
}
67+
4668
const handler = new lambda.SingletonFunction(this, 'CustomResourceHandler', {
4769
uuid: '8693BB64-9689-44B6-9AAF-B0CC9EB8756C',
4870
code: lambda.Code.asset(handlerCodeBundle),
@@ -56,6 +78,13 @@ export class BucketDeployment extends cdk.Construct {
5678

5779
source.bucket.grantRead(handler);
5880
props.destinationBucket.grantReadWrite(handler);
81+
if (props.distribution) {
82+
handler.addToRolePolicy(new iam.PolicyStatement({
83+
effect: iam.Effect.ALLOW,
84+
actions: ['cloudfront:GetInvalidation', 'cloudfront:CreateInvalidation'],
85+
resources: ['*'],
86+
}));
87+
}
5988

6089
new cloudformation.CustomResource(this, 'CustomResource', {
6190
provider: cloudformation.CustomResourceProvider.lambda(handler),
@@ -65,7 +94,9 @@ export class BucketDeployment extends cdk.Construct {
6594
SourceObjectKey: source.zipObjectKey,
6695
DestinationBucketName: props.destinationBucket.bucketName,
6796
DestinationBucketKeyPrefix: props.destinationKeyPrefix,
68-
RetainOnDelete: props.retainOnDelete
97+
RetainOnDelete: props.retainOnDelete,
98+
DistributionId: props.distribution ? props.distribution.distributionId : undefined,
99+
DistributionPaths: props.distributionPaths
69100
}
70101
});
71102
}

packages/@aws-cdk/aws-s3-deployment/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
},
8484
"dependencies": {
8585
"@aws-cdk/aws-cloudformation": "^1.3.0",
86+
"@aws-cdk/aws-cloudfront": "^1.3.0",
8687
"@aws-cdk/aws-iam": "^1.3.0",
8788
"@aws-cdk/aws-lambda": "^1.3.0",
8889
"@aws-cdk/aws-s3": "^1.3.0",
@@ -92,6 +93,7 @@
9293
"homepage": "https://github.com/aws/aws-cdk",
9394
"peerDependencies": {
9495
"@aws-cdk/aws-cloudformation": "^1.3.0",
96+
"@aws-cdk/aws-cloudfront": "^1.3.0",
9597
"@aws-cdk/aws-iam": "^1.3.0",
9698
"@aws-cdk/aws-lambda": "^1.3.0",
9799
"@aws-cdk/aws-s3": "^1.3.0",

0 commit comments

Comments
 (0)