deploy: Support invalidating a CloudFront CDN cache
authorRobert van Gent <rvangent@google.com>
Wed, 1 May 2019 20:25:06 +0000 (13:25 -0700)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Mon, 6 May 2019 19:09:33 +0000 (21:09 +0200)
commands/deploy.go
commands/hugo.go
deploy/cloudfront.go [new file with mode: 0644]
deploy/deploy.go
deploy/deployConfig.go
deploy/deployConfig_test.go
go.mod

index 14e7e1627d27dcdd187b1a80355a1fad3dae75f3..6f8eac357172ad367779bfb85dbcc823f5371d61 100644 (file)
@@ -68,6 +68,7 @@ func newDeployCmd() *deployCmd {
        cc.cmd.Flags().Bool("confirm", false, "ask for confirmation before making changes to the target")
        cc.cmd.Flags().Bool("dryRun", false, "dry run")
        cc.cmd.Flags().Bool("force", false, "force upload of all files")
+       cc.cmd.Flags().Bool("invalidateCDN", true, "invalidate the CDN cache via the CloudFrontDistributionID listed in the deployment target")
        cc.cmd.Flags().Int("maxDeletes", 256, "maximum # of files to delete, or -1 to disable")
 
        return cc
index c6819b054bd88442906d8aa9363c4525f2c56bdb..07f2b95a2bb2bf256f419f3f7942567a6a8bcea4 100644 (file)
@@ -213,6 +213,7 @@ func initializeFlags(cmd *cobra.Command, cfg config.Provider) {
                "force",
                "gc",
                "i18n-warnings",
+               "invalidateCDN",
                "layoutDir",
                "logFile",
                "maxDeletes",
diff --git a/deploy/cloudfront.go b/deploy/cloudfront.go
new file mode 100644 (file)
index 0000000..dbdf9ba
--- /dev/null
@@ -0,0 +1,51 @@
+// Copyright 2019 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package deploy
+
+import (
+       "context"
+       "time"
+
+       "github.com/aws/aws-sdk-go/aws"
+       "github.com/aws/aws-sdk-go/aws/session"
+       "github.com/aws/aws-sdk-go/service/cloudfront"
+)
+
+// InvalidateCloudFront invalidates the CloudFront cache for distributionID.
+// It uses the default AWS credentials from the environment.
+func InvalidateCloudFront(ctx context.Context, distributionID string) error {
+       // SharedConfigEnable enables loading "shared config (~/.aws/config) and
+       // shared credentials (~/.aws/credentials) files".
+       // See https://docs.aws.amazon.com/sdk-for-go/api/aws/session/ for more
+       // details.
+       // This is the same codepath used by Go CDK when creating an s3 URL.
+       // TODO: Update this to a Go CDK helper once available
+       // (https://github.com/google/go-cloud/issues/2003).
+       sess, err := session.NewSessionWithOptions(session.Options{SharedConfigState: session.SharedConfigEnable})
+       if err != nil {
+               return err
+       }
+       req := &cloudfront.CreateInvalidationInput{
+               DistributionId: aws.String(distributionID),
+               InvalidationBatch: &cloudfront.InvalidationBatch{
+                       CallerReference: aws.String(time.Now().Format("20060102150405")),
+                       Paths: &cloudfront.Paths{
+                               Items:    []*string{aws.String("/*")},
+                               Quantity: aws.Int64(1),
+                       },
+               },
+       }
+       _, err = cloudfront.New(sess).CreateInvalidationWithContext(ctx, req)
+       return err
+}
index 6ba348dd86f33023dee7456057448d9c63c643e5..0cea4a9e3e45a5425725f95ad37c7d950e932cc4 100644 (file)
@@ -45,18 +45,19 @@ import (
 type Deployer struct {
        localFs afero.Fs
 
-       targetURL  string     // the Go Cloud blob URL to deploy to
-       matchers   []*matcher // matchers to apply to uploaded files
-       quiet      bool       // true reduces STDOUT
-       confirm    bool       // true enables confirmation before making changes
-       dryRun     bool       // true skips conformations and prints changes instead of applying them
-       force      bool       // true forces upload of all files
-       maxDeletes int        // caps the # of files to delete; -1 to disable
+       target        *target    // the target to deploy to
+       matchers      []*matcher // matchers to apply to uploaded files
+       quiet         bool       // true reduces STDOUT
+       confirm       bool       // true enables confirmation before making changes
+       dryRun        bool       // true skips conformations and prints changes instead of applying them
+       force         bool       // true forces upload of all files
+       invalidateCDN bool       // true enables invalidate CDN cache (if possible)
+       maxDeletes    int        // caps the # of files to delete; -1 to disable
 }
 
 // New constructs a new *Deployer.
 func New(cfg config.Provider, localFs afero.Fs) (*Deployer, error) {
-       target := cfg.GetString("target")
+       targetName := cfg.GetString("target")
 
        // Load the [deployment] section of the config.
        dcfg, err := decodeConfig(cfg)
@@ -65,24 +66,25 @@ func New(cfg config.Provider, localFs afero.Fs) (*Deployer, error) {
        }
 
        // Find the target to deploy to.
-       var targetURL string
+       var tgt *target
        for _, t := range dcfg.Targets {
-               if t.Name == target {
-                       targetURL = t.URL
+               if t.Name == targetName {
+                       tgt = t
                }
        }
-       if targetURL == "" {
-               return nil, fmt.Errorf("deployment target %q not found", target)
+       if tgt == nil {
+               return nil, fmt.Errorf("deployment target %q not found", targetName)
        }
        return &Deployer{
-               localFs:    localFs,
-               targetURL:  targetURL,
-               matchers:   dcfg.Matchers,
-               quiet:      cfg.GetBool("quiet"),
-               confirm:    cfg.GetBool("confirm"),
-               dryRun:     cfg.GetBool("dryRun"),
-               force:      cfg.GetBool("force"),
-               maxDeletes: cfg.GetInt("maxDeletes"),
+               localFs:       localFs,
+               target:        tgt,
+               matchers:      dcfg.Matchers,
+               quiet:         cfg.GetBool("quiet"),
+               confirm:       cfg.GetBool("confirm"),
+               dryRun:        cfg.GetBool("dryRun"),
+               force:         cfg.GetBool("force"),
+               invalidateCDN: cfg.GetBool("invalidateCDN"),
+               maxDeletes:    cfg.GetInt("maxDeletes"),
        }, nil
 }
 
@@ -90,7 +92,7 @@ func New(cfg config.Provider, localFs afero.Fs) (*Deployer, error) {
 func (d *Deployer) Deploy(ctx context.Context) error {
        // TODO: This opens the root path in the bucket/container.
        // Consider adding support for targeting a subdirectory.
-       bucket, err := blob.OpenBucket(ctx, d.targetURL)
+       bucket, err := blob.OpenBucket(ctx, d.target.URL)
        if err != nil {
                return err
        }
@@ -203,9 +205,14 @@ func (d *Deployer) Deploy(ctx context.Context) error {
                jww.FEEDBACK.Println("Success!")
        }
 
-       // TODO: Add support for CloudFront invalidation similar to s3deploy,
-       // and possibly similar functionality for other providers.
-
+       if d.invalidateCDN && d.target.CloudFrontDistributionID != "" {
+               jww.FEEDBACK.Println("Invalidating CloudFront CDN...")
+               if err := InvalidateCloudFront(ctx, d.target.CloudFrontDistributionID); err != nil {
+                       jww.FEEDBACK.Printf("Failed to invalidate CloudFront CDN: %v\n", err)
+                       return err
+               }
+               jww.FEEDBACK.Println("Success!")
+       }
        return nil
 }
 
index 86321e75bc92cf0b47c683c2eeae80d6e2d43861..066fa0ef8027f3aaa7633d2d709e73253cd40326 100644 (file)
@@ -32,6 +32,8 @@ type deployConfig struct {
 type target struct {
        Name string
        URL  string
+
+       CloudFrontDistributionID string
 }
 
 // matcher represents configuration to be applied to files whose paths match
index d7aa9b438d10f29f01379ae94e9577b8b5308d63..3e29d8edf89b853e3d4af8650cda90274321e879 100644 (file)
@@ -32,9 +32,12 @@ someOtherValue = "foo"
 [[deployment.targets]]
 Name = "name1"
 URL = "url1"
+CloudFrontDistributionID = "cdn1"
+
 [[deployment.targets]]
 name = "name2"
 url = "url2"
+cloudfrontdistributionid = "cdn2"
 
 [[deployment.matchers]]
 Pattern = "^pattern1$"
@@ -59,8 +62,10 @@ content-type = "contenttype2"
        assert.Equal(2, len(dcfg.Targets))
        assert.Equal("name1", dcfg.Targets[0].Name)
        assert.Equal("url1", dcfg.Targets[0].URL)
+       assert.Equal("cdn1", dcfg.Targets[0].CloudFrontDistributionID)
        assert.Equal("name2", dcfg.Targets[1].Name)
        assert.Equal("url2", dcfg.Targets[1].URL)
+       assert.Equal("cdn2", dcfg.Targets[1].CloudFrontDistributionID)
 
        assert.Equal(2, len(dcfg.Matchers))
        assert.Equal("^pattern1$", dcfg.Matchers[0].Pattern)
diff --git a/go.mod b/go.mod
index 8bf8c4c75093c5f0e6414349d633b8303c91c527..5189e9b7a820c879498e84103f8b2569e38c3158 100644 (file)
--- a/go.mod
+++ b/go.mod
@@ -8,6 +8,7 @@ require (
        github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38
        github.com/alecthomas/chroma v0.6.3
        github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1 // indirect
+       github.com/aws/aws-sdk-go v1.16.23
        github.com/bep/debounce v1.2.0
        github.com/bep/gitmap v1.0.0
        github.com/bep/go-tocss v0.6.0