diff --git a/commands/deploy.go b/commands/deploy.go index 14e7e1627..6f8eac357 100644 --- a/commands/deploy.go +++ b/commands/deploy.go @@ -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 diff --git a/commands/hugo.go b/commands/hugo.go index c6819b054..07f2b95a2 100644 --- a/commands/hugo.go +++ b/commands/hugo.go @@ -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 index 000000000..dbdf9baf4 --- /dev/null +++ b/deploy/cloudfront.go @@ -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 +} diff --git a/deploy/deploy.go b/deploy/deploy.go index 6ba348dd8..0cea4a9e3 100644 --- a/deploy/deploy.go +++ b/deploy/deploy.go @@ -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 } diff --git a/deploy/deployConfig.go b/deploy/deployConfig.go index 86321e75b..066fa0ef8 100644 --- a/deploy/deployConfig.go +++ b/deploy/deployConfig.go @@ -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 diff --git a/deploy/deployConfig_test.go b/deploy/deployConfig_test.go index d7aa9b438..3e29d8edf 100644 --- a/deploy/deployConfig_test.go +++ b/deploy/deployConfig_test.go @@ -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 8bf8c4c75..5189e9b7a 100644 --- 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