Skip to content

Commit

Permalink
Add CloudFront cache invalidation
Browse files Browse the repository at this point in the history
Fixes #31
  • Loading branch information
bep committed Oct 3, 2018
1 parent 9f84015 commit 45701db
Show file tree
Hide file tree
Showing 14 changed files with 564 additions and 76 deletions.
40 changes: 25 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,33 +25,35 @@ Note that `s3deploy` is a perfect tool to use with a continuous integration tool

```bash
Usage of s3deploy:
-V print version and exit
-V print version and exit
-bucket string
destination bucket name on AWS
destination bucket name on AWS
-config string
optional config file (default ".s3deploy.yml")
optional config file (default ".s3deploy.yml")
-distribution-id string
optional CDN distribution ID for cache invalidation
-force
upload even if the etags match
-h help
upload even if the etags match
-h help
-key string
access key ID for AWS
access key ID for AWS
-max-delete int
maximum number of files to delete per deploy (default 256)
maximum number of files to delete per deploy (default 256)
-path string
optional bucket sub path
optional bucket sub path
-quiet
enable silent mode
enable silent mode
-region string
name of AWS region
name of AWS region
-secret string
secret access key for AWS
secret access key for AWS
-source string
path of files to upload (default ".")
path of files to upload (default ".")
-try
trial run, no remote updates
-v enable verbose logging
trial run, no remote updates
-v enable verbose logging
-workers int
number of workers to upload files (default -1)
number of workers to upload files (default -1)
```
### Notes
Expand Down Expand Up @@ -128,6 +130,14 @@ routes:
```
Replace <bucketname> with your own.
## CloudFront CDN Cache Invalidation
If you have configured CloudFront CDN in front of your S3 bucket, you can supply the `distribution-id` as a flag. This will make sure to invalidate the cache for the updated files after the deployment to S3. Note that the AWS user must have the needed access rights.
Note that CloudFront allows [1,000 paths per month at no charge](https://aws.amazon.com/blogs/aws/simplified-multiple-object-invalidation-for-amazon-cloudfront/), so S3deploy tries to be smart about the invalidation strategy; we try to reduce the number of paths to 8. If that isn't possible, we will fall back to a full invalidation, e.g. "/*".
TODO(bep) IAM Policy example.
## Background Information
Expand Down
17 changes: 17 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,26 @@ module github.com/bep/s3deploy

require (
github.com/aws/aws-sdk-go v1.15.27
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780
github.com/fsnotify/fsnotify v1.4.7 // indirect
github.com/gopherjs/gopherjs v0.0.0-20181003052733-bf5fc7d38140 // indirect
github.com/jtolds/gls v4.2.1+incompatible // indirect
github.com/kisielk/gotool v1.0.0 // indirect
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86 // indirect
github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371 // indirect
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d // indirect
github.com/smartystreets/goconvey v0.0.0-20180222194500-ef6db91d284a // indirect
github.com/spf13/cobra v0.0.3 // indirect
github.com/spf13/pflag v1.0.3 // indirect
github.com/stretchr/testify v1.2.2
golang.org/x/crypto v0.0.0-20181001203147-e3636079e1a4 // indirect
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d // indirect
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f
golang.org/x/sys v0.0.0-20181003145944-af653ce8b74f // indirect
golang.org/x/text v0.3.0
golang.org/x/tools v0.0.0-20181003164445-c930a8531daf // indirect
gopkg.in/yaml.v2 v2.2.1
)
35 changes: 35 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,17 +1,52 @@
github.com/aws/aws-sdk-go v1.15.27 h1:i75BxN4Es/8rTVQbEKAP1WCiIhhz635xTNeDdZJRAXQ=
github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780 h1:tFh1tRc4CA31yP6qDcu+Trax5wW5GuMxvkIba07qVLY=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/go-ini/ini v1.25.4 h1:Mujh4R/dH6YL8bxuISne3xX2+qcQ9p0IxKAP6ExWoUo=
github.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/gopherjs/gopherjs v0.0.0-20181003052733-bf5fc7d38140 h1:xUNgm/qAe2iOwkqf230y9I1zOlqgq0gjOJ3RcIIJIoQ=
github.com/gopherjs/gopherjs v0.0.0-20181003052733-bf5fc7d38140/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8 h1:12VvqtR6Aowv3l/EQUlocDHW2Cp4G9WJVH7uyH8QFJE=
github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE=
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86 h1:D6paGObi5Wud7xg83MaEFyjxQB1W5bz5d0IFppr+ymk=
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab h1:eFXv9Nu1lGbrNbj619aWwZfVF5HBrm9Plte8aNptuTI=
github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371 h1:SWV2fHctRpRrp49VXJ6UZja7gU9QLHwRpIPBN89SKEo=
github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v0.0.0-20180222194500-ef6db91d284a h1:JSvGDIbmil4Ui/dDdFBExb7/cmkNjyX5F97oglmvCDo=
github.com/smartystreets/goconvey v0.0.0-20180222194500-ef6db91d284a/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8=
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
golang.org/x/crypto v0.0.0-20181001203147-e3636079e1a4 h1:Vk3wNqEZwyGyei9yq5ekj7frek2u7HUfffJ1/opblzc=
golang.org/x/crypto v0.0.0-20181001203147-e3636079e1a4/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d h1:g9qWBGx4puODJTMVyoPrpoxPFgVGd+z1DZwjfRu4d0I=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20181003145944-af653ce8b74f h1:zAtpFwFDtnvBWPPelq8CSiqRN1wrIzMUk9dwzbpjpNM=
golang.org/x/sys v0.0.0-20181003145944-af653ce8b74f/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20181003164445-c930a8531daf h1:5wIhpNrul45Er8vIy8iqSCUsCEQH7G5YtJ4CfOi4Fk0=
golang.org/x/tools v0.0.0-20181003164445-c930a8531daf/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
201 changes: 201 additions & 0 deletions lib/cloudfront.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// Copyright © 2018 Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>.
//
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.

package lib

import (
"errors"
"path"
"sort"
"strings"
"time"

"github.com/aws/aws-sdk-go/aws"

"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/cloudfront"
)

var _ remoteCDN = (*cloudFrontClient)(nil)

type cloudFrontClient struct {
// The CloudFront distribution ID
distributionID string

// Will invalidate the entire cache, e.g. "/*"
force bool
bucketPath string

logger printer
cf *cloudfront.CloudFront
}

func newCloudFrontClient(
sess *session.Session,
logger printer,
cfg Config) (*cloudFrontClient, error) {
if cfg.CDNDistributionID == "" {
return nil, errors.New("must provide a distribution ID")
}
return &cloudFrontClient{
distributionID: cfg.CDNDistributionID,
force: cfg.Force,
bucketPath: cfg.BucketPath,
logger: logger,
cf: cloudfront.New(sess),
}, nil
}

func (c *cloudFrontClient) InvalidateCDNCache(paths ...string) error {
if len(paths) == 0 {
return nil
}

dcfg, err := c.cf.GetDistribution(&cloudfront.GetDistributionInput{
Id: &c.distributionID,
})
if err != nil {
return err
}

originPath := *dcfg.Distribution.DistributionConfig.Origins.Items[0].OriginPath
var root string
if originPath != "" || c.bucketPath != "" {
bucket := strings.TrimPrefix(c.bucketPath, "/")
origin := strings.TrimPrefix(originPath, "/")
root = strings.TrimPrefix(bucket, origin)
subPath := strings.TrimPrefix(origin, bucket)
for i, p := range paths {
paths[i] = strings.TrimPrefix(p, subPath)
}
}

// This will try to reduce the number of invaldation paths to maximum 8.
// If that isn't possible it will fall back to a full invalidation, e.g. "/*".
// CloudFront allows 1000 free invalidations per month. After that they
// cost money, so we want to keep this down.
paths = normalizeInvalidationPaths(root, 8, c.force, paths...)

if len(paths) > 10 {
c.logger.Printf("Create CloudFront invalidation request for %d paths", len(paths))
} else {
c.logger.Printf("Create CloudFront invalidation request for %v", paths)
}

in := &cloudfront.CreateInvalidationInput{
DistributionId: &c.distributionID,
InvalidationBatch: c.pathsToInvalidationBatch(time.Now().Format("20060102150405"), paths...),
}

_, err = c.cf.CreateInvalidation(
in,
)

return err
}

func (*cloudFrontClient) pathsToInvalidationBatch(ref string, paths ...string) *cloudfront.InvalidationBatch {
batch := &cloudfront.InvalidationBatch{
CallerReference: &ref,
}
cfpaths := &cloudfront.Paths{}
for _, p := range paths {
cfpaths.Items = append(cfpaths.Items, aws.String(p))
}

qty := int64(len(paths))
cfpaths.Quantity = &qty
batch.SetPaths(cfpaths)
return batch
}

// For path rules, see https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Invalidation.html
func normalizeInvalidationPaths(
root string,
threshold int,
force bool,
paths ...string) []string {

if !strings.HasPrefix(root, "/") {
root = "/" + root
}

matchAll := path.Join(root, "*/")
clearAll := []string{matchAll}

if force {
return clearAll
}

var normalized []string
var maxlevels int

for _, p := range paths {
p = path.Clean(p)
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
levels := strings.Count(p, "/")
if levels > maxlevels {
maxlevels = levels
}

if strings.HasSuffix(p, "index.html") {
dir := path.Dir(p)
if !strings.HasSuffix(dir, "/") {
dir += "/"
}
normalized = append(normalized, dir)
} else {
normalized = append(normalized, p)
}
}

normalized = uniqueStrings(uniqueStrings(normalized))
sort.Strings(normalized)

if len(normalized) > threshold {
normalized = uniqueStrings(normalized)
if len(normalized) > threshold {
for k := maxlevels; k > 0; k-- {
for i, p := range normalized {
if strings.Count(p, "/") > k {
parts := strings.Split(strings.TrimPrefix(path.Dir(p), "/"), "/")
normalized[i] = "/" + path.Join(parts[0:len(parts)-k+1]...) + "/*"
}
}
normalized = uniqueStrings(normalized)
if len(normalized) <= threshold {
break
}
}

if len(normalized) > threshold {
// Give up.
return clearAll
}
}
}

for _, pattern := range normalized {
if pattern == matchAll {
return clearAll
}
}

return normalized
}

func uniqueStrings(s []string) []string {
var unique []string
set := map[string]interface{}{}
for _, val := range s {
if _, ok := set[val]; !ok {
unique = append(unique, val)
set[val] = val
}
}
return unique
}

0 comments on commit 45701db

Please sign in to comment.