]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
Avoid impporting deploy from config when nodeploy tag is set
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Wed, 7 Feb 2024 17:24:02 +0000 (18:24 +0100)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Wed, 7 Feb 2024 19:38:13 +0000 (20:38 +0100)
Test:

```
go list -tags nodeploy ./... | grep deploy
```

Fixes #12009

commands/deploy.go
config/allconfig/allconfig.go
config/allconfig/alldecoders.go
deploy/cloudfront.go
deploy/deploy.go
deploy/deployConfig.go [deleted file]
deploy/deployConfig_test.go [deleted file]
deploy/deploy_test.go
deploy/deployconfig/deployConfig.go [new file with mode: 0644]
deploy/deployconfig/deployConfig_test.go [new file with mode: 0644]

index ca6e4d60edb197aa364ece90e2c33f5bbf551700..f5354f74d41dbea404766b261a97cda975bd5311 100644 (file)
@@ -32,8 +32,10 @@ package commands
 import (
        "context"
 
-       "github.com/bep/simplecobra"
        "github.com/gohugoio/hugo/deploy"
+       "github.com/gohugoio/hugo/deploy/deployconfig"
+
+       "github.com/bep/simplecobra"
        "github.com/spf13/cobra"
 )
 
@@ -62,9 +64,9 @@ documentation.
                        cmd.Flags().Bool("confirm", false, "ask for confirmation before making changes to the target")
                        cmd.Flags().Bool("dryRun", false, "dry run")
                        cmd.Flags().Bool("force", false, "force upload of all files")
-                       cmd.Flags().Bool("invalidateCDN", deploy.DefaultConfig.InvalidateCDN, "invalidate the CDN cache listed in the deployment target")
-                       cmd.Flags().Int("maxDeletes", deploy.DefaultConfig.MaxDeletes, "maximum # of files to delete, or -1 to disable")
-                       cmd.Flags().Int("workers", deploy.DefaultConfig.Workers, "number of workers to transfer files. defaults to 10")
+                       cmd.Flags().Bool("invalidateCDN", deployconfig.DefaultConfig.InvalidateCDN, "invalidate the CDN cache listed in the deployment target")
+                       cmd.Flags().Int("maxDeletes", deployconfig.DefaultConfig.MaxDeletes, "maximum # of files to delete, or -1 to disable")
+                       cmd.Flags().Int("workers", deployconfig.DefaultConfig.Workers, "number of workers to transfer files. defaults to 10")
                },
        }
 }
index 4771f5a7222c54d487842e9d37b23754fcf79bfa..f22fe8a7eb5624fbb18d20d564a042d04b96cd5c 100644 (file)
@@ -37,7 +37,7 @@ import (
        "github.com/gohugoio/hugo/config/privacy"
        "github.com/gohugoio/hugo/config/security"
        "github.com/gohugoio/hugo/config/services"
-       "github.com/gohugoio/hugo/deploy"
+       "github.com/gohugoio/hugo/deploy/deployconfig"
        "github.com/gohugoio/hugo/helpers"
        "github.com/gohugoio/hugo/langs"
        "github.com/gohugoio/hugo/markup/markup_config"
@@ -141,8 +141,8 @@ type Config struct {
        // <docsmeta>{"refs": ["config:languages:menus"] }</docsmeta>
        Menus *config.ConfigNamespace[map[string]navigation.MenuConfig, navigation.Menus] `mapstructure:"-"`
 
-       // The deployment configuration section contains for hugo deploy.
-       Deployment deploy.DeployConfig `mapstructure:"-"`
+       // The deployment configuration section contains for hugo deployconfig.
+       Deployment deployconfig.DeployConfig `mapstructure:"-"`
 
        // Module configuration.
        Module modules.Config `mapstructure:"-"`
index 5ab76dec59f679e1f0627fc483d48dd56c49b4b8..9c1d08144a32a7036cb82f8e6c5ee25bc4991bab 100644 (file)
@@ -25,7 +25,7 @@ import (
        "github.com/gohugoio/hugo/config/privacy"
        "github.com/gohugoio/hugo/config/security"
        "github.com/gohugoio/hugo/config/services"
-       "github.com/gohugoio/hugo/deploy"
+       "github.com/gohugoio/hugo/deploy/deployconfig"
        "github.com/gohugoio/hugo/langs"
        "github.com/gohugoio/hugo/markup/markup_config"
        "github.com/gohugoio/hugo/media"
@@ -333,7 +333,7 @@ var allDecoderSetups = map[string]decodeWeight{
                key: "deployment",
                decode: func(d decodeWeight, p decodeConfig) error {
                        var err error
-                       p.c.Deployment, err = deploy.DecodeConfig(p.p)
+                       p.c.Deployment, err = deployconfig.DecodeConfig(p.p)
                        return err
                },
        },
index 91453dfb96a7e16815e06662b1daaafcab75ea5e..4481eb52aa7378e2bb122502729d548f72df83ad 100644 (file)
@@ -24,6 +24,7 @@ import (
        "github.com/aws/aws-sdk-go-v2/aws"
        "github.com/aws/aws-sdk-go-v2/service/cloudfront"
        "github.com/aws/aws-sdk-go-v2/service/cloudfront/types"
+       "github.com/gohugoio/hugo/deploy/deployconfig"
        gcaws "gocloud.dev/aws"
 )
 
@@ -38,7 +39,7 @@ var v2ConfigValidParams = map[string]bool{
 
 // InvalidateCloudFront invalidates the CloudFront cache for distributionID.
 // Uses AWS credentials config from the bucket URL.
-func InvalidateCloudFront(ctx context.Context, target *Target) error {
+func InvalidateCloudFront(ctx context.Context, target *deployconfig.Target) error {
        u, err := url.Parse(target.URL)
        if err != nil {
                return err
index 7c496c72df82fc38d3ad2dbecc5f5fd823146f20..b2a8a88138ba0045cb438b63b2deeae096069534 100644 (file)
@@ -38,6 +38,7 @@ import (
        "github.com/gobwas/glob"
        "github.com/gohugoio/hugo/common/loggers"
        "github.com/gohugoio/hugo/config"
+       "github.com/gohugoio/hugo/deploy/deployconfig"
        "github.com/gohugoio/hugo/media"
        "github.com/spf13/afero"
        "golang.org/x/text/unicode/norm"
@@ -57,10 +58,10 @@ type Deployer struct {
        mediaTypes media.Types // Hugo's MediaType to guess ContentType
        quiet      bool        // true reduces STDOUT // TODO(bep) remove, this is a global feature.
 
-       cfg    DeployConfig
+       cfg    deployconfig.DeployConfig
        logger loggers.Logger
 
-       target *Target // the target to deploy to
+       target *deployconfig.Target // the target to deploy to
 
        // For tests...
        summary deploySummary // summary of latest Deploy results
@@ -74,7 +75,7 @@ const metaMD5Hash = "md5chksum" // the meta key to store md5hash in
 
 // New constructs a new *Deployer.
 func New(cfg config.AllProvider, logger loggers.Logger, localFs afero.Fs) (*Deployer, error) {
-       dcfg := cfg.GetConfigSection(deploymentConfigKey).(DeployConfig)
+       dcfg := cfg.GetConfigSection(deployconfig.DeploymentConfigKey).(deployconfig.DeployConfig)
        targetName := dcfg.Target
 
        if len(dcfg.Targets) == 0 {
@@ -83,7 +84,7 @@ func New(cfg config.AllProvider, logger loggers.Logger, localFs afero.Fs) (*Depl
        mediaTypes := cfg.GetConfigSection("mediaTypes").(media.Types)
 
        // Find the target to deploy to.
-       var tgt *Target
+       var tgt *deployconfig.Target
        if targetName == "" {
                // Default to the first target.
                tgt = dcfg.Targets[0]
@@ -133,7 +134,7 @@ func (d *Deployer) Deploy(ctx context.Context) error {
        // Load local files from the source directory.
        var include, exclude glob.Glob
        if d.target != nil {
-               include, exclude = d.target.includeGlob, d.target.excludeGlob
+               include, exclude = d.target.IncludeGlob, d.target.ExcludeGlob
        }
        local, err := d.walkLocal(d.localFs, d.cfg.Matchers, include, exclude, d.mediaTypes)
        if err != nil {
@@ -178,7 +179,7 @@ func (d *Deployer) Deploy(ctx context.Context) error {
 
        // Order the uploads. They are organized in groups; all uploads in a group
        // must be complete before moving on to the next group.
-       uploadGroups := applyOrdering(d.cfg.ordering, uploads)
+       uploadGroups := applyOrdering(d.cfg.Ordering, uploads)
 
        nParallel := d.cfg.Workers
        var errs []error
@@ -343,14 +344,14 @@ type localFile struct {
        UploadSize int64
 
        fs         afero.Fs
-       matcher    *Matcher
+       matcher    *deployconfig.Matcher
        md5        []byte       // cache
        gzipped    bytes.Buffer // cached of gzipped contents if gzipping
        mediaTypes media.Types
 }
 
 // newLocalFile initializes a *localFile.
-func newLocalFile(fs afero.Fs, nativePath, slashpath string, m *Matcher, mt media.Types) (*localFile, error) {
+func newLocalFile(fs afero.Fs, nativePath, slashpath string, m *deployconfig.Matcher, mt media.Types) (*localFile, error) {
        f, err := fs.Open(nativePath)
        if err != nil {
                return nil, err
@@ -482,7 +483,7 @@ func knownHiddenDirectory(name string) bool {
 
 // walkLocal walks the source directory and returns a flat list of files,
 // using localFile.SlashPath as the map keys.
-func (d *Deployer) walkLocal(fs afero.Fs, matchers []*Matcher, include, exclude glob.Glob, mediaTypes media.Types) (map[string]*localFile, error) {
+func (d *Deployer) walkLocal(fs afero.Fs, matchers []*deployconfig.Matcher, include, exclude glob.Glob, mediaTypes media.Types) (map[string]*localFile, error) {
        retval := map[string]*localFile{}
        err := afero.Walk(fs, "", func(path string, info os.FileInfo, err error) error {
                if err != nil {
@@ -521,7 +522,7 @@ func (d *Deployer) walkLocal(fs afero.Fs, matchers []*Matcher, include, exclude
                }
 
                // Find the first matching matcher (if any).
-               var m *Matcher
+               var m *deployconfig.Matcher
                for _, cur := range matchers {
                        if cur.Matches(slashpath) {
                                m = cur
diff --git a/deploy/deployConfig.go b/deploy/deployConfig.go
deleted file mode 100644 (file)
index f9454b3..0000000
+++ /dev/null
@@ -1,174 +0,0 @@
-// 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 (
-       "errors"
-       "fmt"
-       "regexp"
-
-       "github.com/gobwas/glob"
-       "github.com/gohugoio/hugo/config"
-       hglob "github.com/gohugoio/hugo/hugofs/glob"
-       "github.com/mitchellh/mapstructure"
-)
-
-const deploymentConfigKey = "deployment"
-
-// DeployConfig is the complete configuration for deployment.
-type DeployConfig struct {
-       Targets  []*Target
-       Matchers []*Matcher
-       Order    []string
-
-       // Usually set via flags.
-       // Target deployment Name; defaults to the first one.
-       Target string
-       // Show a confirm prompt before deploying.
-       Confirm bool
-       // DryRun will try the deployment without any remote changes.
-       DryRun bool
-       // Force will re-upload all files.
-       Force bool
-       // Invalidate the CDN cache listed in the deployment target.
-       InvalidateCDN bool
-       // MaxDeletes is the maximum number of files to delete.
-       MaxDeletes int
-       // Number of concurrent workers to use when uploading files.
-       Workers int
-
-       ordering []*regexp.Regexp // compiled Order
-}
-
-type Target struct {
-       Name string
-       URL  string
-
-       CloudFrontDistributionID string
-
-       // GoogleCloudCDNOrigin specifies the Google Cloud project and CDN origin to
-       // invalidate when deploying this target.  It is specified as <project>/<origin>.
-       GoogleCloudCDNOrigin string
-
-       // Optional patterns of files to include/exclude for this target.
-       // Parsed using github.com/gobwas/glob.
-       Include string
-       Exclude string
-
-       // Parsed versions of Include/Exclude.
-       includeGlob glob.Glob
-       excludeGlob glob.Glob
-}
-
-func (tgt *Target) parseIncludeExclude() error {
-       var err error
-       if tgt.Include != "" {
-               tgt.includeGlob, err = hglob.GetGlob(tgt.Include)
-               if err != nil {
-                       return fmt.Errorf("invalid deployment.target.include %q: %v", tgt.Include, err)
-               }
-       }
-       if tgt.Exclude != "" {
-               tgt.excludeGlob, err = hglob.GetGlob(tgt.Exclude)
-               if err != nil {
-                       return fmt.Errorf("invalid deployment.target.exclude %q: %v", tgt.Exclude, err)
-               }
-       }
-       return nil
-}
-
-// Matcher represents configuration to be applied to files whose paths match
-// a specified pattern.
-type Matcher struct {
-       // Pattern is the string pattern to match against paths.
-       // Matching is done against paths converted to use / as the path separator.
-       Pattern string
-
-       // CacheControl specifies caching attributes to use when serving the blob.
-       // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
-       CacheControl string
-
-       // ContentEncoding specifies the encoding used for the blob's content, if any.
-       // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding
-       ContentEncoding string
-
-       // ContentType specifies the MIME type of the blob being written.
-       // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type
-       ContentType string
-
-       // Gzip determines whether the file should be gzipped before upload.
-       // If so, the ContentEncoding field will automatically be set to "gzip".
-       Gzip bool
-
-       // Force indicates that matching files should be re-uploaded. Useful when
-       // other route-determined metadata (e.g., ContentType) has changed.
-       Force bool
-
-       // re is Pattern compiled.
-       re *regexp.Regexp
-}
-
-func (m *Matcher) Matches(path string) bool {
-       return m.re.MatchString(path)
-}
-
-var DefaultConfig = DeployConfig{
-       Workers:       10,
-       InvalidateCDN: true,
-       MaxDeletes:    256,
-}
-
-// DecodeConfig creates a config from a given Hugo configuration.
-func DecodeConfig(cfg config.Provider) (DeployConfig, error) {
-       dcfg := DefaultConfig
-
-       if !cfg.IsSet(deploymentConfigKey) {
-               return dcfg, nil
-       }
-       if err := mapstructure.WeakDecode(cfg.GetStringMap(deploymentConfigKey), &dcfg); err != nil {
-               return dcfg, err
-       }
-
-       if dcfg.Workers <= 0 {
-               dcfg.Workers = 10
-       }
-
-       for _, tgt := range dcfg.Targets {
-               if *tgt == (Target{}) {
-                       return dcfg, errors.New("empty deployment target")
-               }
-               if err := tgt.parseIncludeExclude(); err != nil {
-                       return dcfg, err
-               }
-       }
-       var err error
-       for _, m := range dcfg.Matchers {
-               if *m == (Matcher{}) {
-                       return dcfg, errors.New("empty deployment matcher")
-               }
-               m.re, err = regexp.Compile(m.Pattern)
-               if err != nil {
-                       return dcfg, fmt.Errorf("invalid deployment.matchers.pattern: %v", err)
-               }
-       }
-       for _, o := range dcfg.Order {
-               re, err := regexp.Compile(o)
-               if err != nil {
-                       return dcfg, fmt.Errorf("invalid deployment.orderings.pattern: %v", err)
-               }
-               dcfg.ordering = append(dcfg.ordering, re)
-       }
-
-       return dcfg, nil
-}
diff --git a/deploy/deployConfig_test.go b/deploy/deployConfig_test.go
deleted file mode 100644 (file)
index 2dbe187..0000000
+++ /dev/null
@@ -1,199 +0,0 @@
-// 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.
-
-//go:build !nodeploy
-// +build !nodeploy
-
-package deploy
-
-import (
-       "fmt"
-       "testing"
-
-       qt "github.com/frankban/quicktest"
-       "github.com/gohugoio/hugo/config"
-)
-
-func TestDecodeConfigFromTOML(t *testing.T) {
-       c := qt.New(t)
-
-       tomlConfig := `
-
-someOtherValue = "foo"
-
-[deployment]
-
-order = ["o1", "o2"]
-
-# All lowercase.
-[[deployment.targets]]
-name = "name0"
-url = "url0"
-cloudfrontdistributionid = "cdn0"
-include = "*.html"
-
-# All uppercase.
-[[deployment.targets]]
-NAME = "name1"
-URL = "url1"
-CLOUDFRONTDISTRIBUTIONID = "cdn1"
-INCLUDE = "*.jpg"
-
-# Camelcase.
-[[deployment.targets]]
-name = "name2"
-url = "url2"
-cloudFrontDistributionID = "cdn2"
-exclude = "*.png"
-
-# All lowercase.
-[[deployment.matchers]]
-pattern = "^pattern0$"
-cachecontrol = "cachecontrol0"
-contentencoding = "contentencoding0"
-contenttype = "contenttype0"
-
-# All uppercase.
-[[deployment.matchers]]
-PATTERN = "^pattern1$"
-CACHECONTROL = "cachecontrol1"
-CONTENTENCODING = "contentencoding1"
-CONTENTTYPE = "contenttype1"
-GZIP = true
-FORCE = true
-
-# Camelcase.
-[[deployment.matchers]]
-pattern = "^pattern2$"
-cacheControl = "cachecontrol2"
-contentEncoding = "contentencoding2"
-contentType = "contenttype2"
-gzip = true
-force = true
-`
-       cfg, err := config.FromConfigString(tomlConfig, "toml")
-       c.Assert(err, qt.IsNil)
-
-       dcfg, err := DecodeConfig(cfg)
-       c.Assert(err, qt.IsNil)
-
-       // Order.
-       c.Assert(len(dcfg.Order), qt.Equals, 2)
-       c.Assert(dcfg.Order[0], qt.Equals, "o1")
-       c.Assert(dcfg.Order[1], qt.Equals, "o2")
-       c.Assert(len(dcfg.ordering), qt.Equals, 2)
-
-       // Targets.
-       c.Assert(len(dcfg.Targets), qt.Equals, 3)
-       wantInclude := []string{"*.html", "*.jpg", ""}
-       wantExclude := []string{"", "", "*.png"}
-       for i := 0; i < 3; i++ {
-               tgt := dcfg.Targets[i]
-               c.Assert(tgt.Name, qt.Equals, fmt.Sprintf("name%d", i))
-               c.Assert(tgt.URL, qt.Equals, fmt.Sprintf("url%d", i))
-               c.Assert(tgt.CloudFrontDistributionID, qt.Equals, fmt.Sprintf("cdn%d", i))
-               c.Assert(tgt.Include, qt.Equals, wantInclude[i])
-               if wantInclude[i] != "" {
-                       c.Assert(tgt.includeGlob, qt.Not(qt.IsNil))
-               }
-               c.Assert(tgt.Exclude, qt.Equals, wantExclude[i])
-               if wantExclude[i] != "" {
-                       c.Assert(tgt.excludeGlob, qt.Not(qt.IsNil))
-               }
-       }
-
-       // Matchers.
-       c.Assert(len(dcfg.Matchers), qt.Equals, 3)
-       for i := 0; i < 3; i++ {
-               m := dcfg.Matchers[i]
-               c.Assert(m.Pattern, qt.Equals, fmt.Sprintf("^pattern%d$", i))
-               c.Assert(m.re, qt.Not(qt.IsNil))
-               c.Assert(m.CacheControl, qt.Equals, fmt.Sprintf("cachecontrol%d", i))
-               c.Assert(m.ContentEncoding, qt.Equals, fmt.Sprintf("contentencoding%d", i))
-               c.Assert(m.ContentType, qt.Equals, fmt.Sprintf("contenttype%d", i))
-               c.Assert(m.Gzip, qt.Equals, i != 0)
-               c.Assert(m.Force, qt.Equals, i != 0)
-       }
-}
-
-func TestInvalidOrderingPattern(t *testing.T) {
-       c := qt.New(t)
-
-       tomlConfig := `
-
-someOtherValue = "foo"
-
-[deployment]
-order = ["["]  # invalid regular expression
-`
-       cfg, err := config.FromConfigString(tomlConfig, "toml")
-       c.Assert(err, qt.IsNil)
-
-       _, err = DecodeConfig(cfg)
-       c.Assert(err, qt.Not(qt.IsNil))
-}
-
-func TestInvalidMatcherPattern(t *testing.T) {
-       c := qt.New(t)
-
-       tomlConfig := `
-
-someOtherValue = "foo"
-
-[deployment]
-[[deployment.matchers]]
-Pattern = "["  # invalid regular expression
-`
-       cfg, err := config.FromConfigString(tomlConfig, "toml")
-       c.Assert(err, qt.IsNil)
-
-       _, err = DecodeConfig(cfg)
-       c.Assert(err, qt.Not(qt.IsNil))
-}
-
-func TestDecodeConfigDefault(t *testing.T) {
-       c := qt.New(t)
-
-       dcfg, err := DecodeConfig(config.New())
-       c.Assert(err, qt.IsNil)
-       c.Assert(len(dcfg.Targets), qt.Equals, 0)
-       c.Assert(len(dcfg.Matchers), qt.Equals, 0)
-}
-
-func TestEmptyTarget(t *testing.T) {
-       c := qt.New(t)
-
-       tomlConfig := `
-[deployment]
-[[deployment.targets]]
-`
-       cfg, err := config.FromConfigString(tomlConfig, "toml")
-       c.Assert(err, qt.IsNil)
-
-       _, err = DecodeConfig(cfg)
-       c.Assert(err, qt.Not(qt.IsNil))
-}
-
-func TestEmptyMatcher(t *testing.T) {
-       c := qt.New(t)
-
-       tomlConfig := `
-[deployment]
-[[deployment.matchers]]
-`
-       cfg, err := config.FromConfigString(tomlConfig, "toml")
-       c.Assert(err, qt.IsNil)
-
-       _, err = DecodeConfig(cfg)
-       c.Assert(err, qt.Not(qt.IsNil))
-}
index d220ab7c2a0823b6ad453899e4d530f413a0612e..12967fba071c58e96133afaeb23619c2361e08ee 100644 (file)
@@ -31,6 +31,7 @@ import (
        "testing"
 
        "github.com/gohugoio/hugo/common/loggers"
+       "github.com/gohugoio/hugo/deploy/deployconfig"
        "github.com/gohugoio/hugo/hugofs"
        "github.com/gohugoio/hugo/media"
        "github.com/google/go-cmp/cmp"
@@ -110,7 +111,7 @@ func TestFindDiffs(t *testing.T) {
                {
                        Description: "local == remote with route.Force true -> diffs",
                        Local: []*localFile{
-                               {NativePath: "aaa", SlashPath: "aaa", UploadSize: 1, matcher: &Matcher{Force: true}, md5: hash1},
+                               {NativePath: "aaa", SlashPath: "aaa", UploadSize: 1, matcher: &deployconfig.Matcher{Force: true}, md5: hash1},
                                makeLocal("bbb", 2, hash1),
                        },
                        Remote: []*blob.ListObject{
@@ -293,7 +294,7 @@ func TestLocalFile(t *testing.T) {
        tests := []struct {
                Description         string
                Path                string
-               Matcher             *Matcher
+               Matcher             *deployconfig.Matcher
                MediaTypesConfig    map[string]any
                WantContent         []byte
                WantSize            int64
@@ -319,7 +320,7 @@ func TestLocalFile(t *testing.T) {
                {
                        Description:      "CacheControl from matcher",
                        Path:             "foo.txt",
-                       Matcher:          &Matcher{CacheControl: "max-age=630720000"},
+                       Matcher:          &deployconfig.Matcher{CacheControl: "max-age=630720000"},
                        WantContent:      contentBytes,
                        WantSize:         contentLen,
                        WantMD5:          contentMD5[:],
@@ -328,7 +329,7 @@ func TestLocalFile(t *testing.T) {
                {
                        Description:         "ContentEncoding from matcher",
                        Path:                "foo.txt",
-                       Matcher:             &Matcher{ContentEncoding: "foobar"},
+                       Matcher:             &deployconfig.Matcher{ContentEncoding: "foobar"},
                        WantContent:         contentBytes,
                        WantSize:            contentLen,
                        WantMD5:             contentMD5[:],
@@ -337,7 +338,7 @@ func TestLocalFile(t *testing.T) {
                {
                        Description:     "ContentType from matcher",
                        Path:            "foo.txt",
-                       Matcher:         &Matcher{ContentType: "foo/bar"},
+                       Matcher:         &deployconfig.Matcher{ContentType: "foo/bar"},
                        WantContent:     contentBytes,
                        WantSize:        contentLen,
                        WantMD5:         contentMD5[:],
@@ -346,7 +347,7 @@ func TestLocalFile(t *testing.T) {
                {
                        Description:         "gzipped content",
                        Path:                "foo.txt",
-                       Matcher:             &Matcher{Gzip: true},
+                       Matcher:             &deployconfig.Matcher{Gzip: true},
                        WantContent:         gzBytes,
                        WantSize:            gzLen,
                        WantMD5:             gzMD5[:],
@@ -560,7 +561,7 @@ func TestEndToEndSync(t *testing.T) {
                                localFs:    test.fs,
                                bucket:     test.bucket,
                                mediaTypes: media.DefaultTypes,
-                               cfg:        DeployConfig{MaxDeletes: -1},
+                               cfg:        deployconfig.DeployConfig{MaxDeletes: -1},
                        }
 
                        // Initial deployment should sync remote with local.
@@ -643,7 +644,7 @@ func TestMaxDeletes(t *testing.T) {
                                localFs:    test.fs,
                                bucket:     test.bucket,
                                mediaTypes: media.DefaultTypes,
-                               cfg:        DeployConfig{MaxDeletes: -1},
+                               cfg:        deployconfig.DeployConfig{MaxDeletes: -1},
                        }
 
                        // Sync remote with local.
@@ -764,16 +765,16 @@ func TestIncludeExclude(t *testing.T) {
                        if err != nil {
                                t.Fatal(err)
                        }
-                       tgt := &Target{
+                       tgt := &deployconfig.Target{
                                Include: test.Include,
                                Exclude: test.Exclude,
                        }
-                       if err := tgt.parseIncludeExclude(); err != nil {
+                       if err := tgt.ParseIncludeExclude(); err != nil {
                                t.Error(err)
                        }
                        deployer := &Deployer{
                                localFs: fsTest.fs,
-                               cfg:     DeployConfig{MaxDeletes: -1}, bucket: fsTest.bucket,
+                               cfg:     deployconfig.DeployConfig{MaxDeletes: -1}, bucket: fsTest.bucket,
                                target:     tgt,
                                mediaTypes: media.DefaultTypes,
                        }
@@ -830,7 +831,7 @@ func TestIncludeExcludeRemoteDelete(t *testing.T) {
                        }
                        deployer := &Deployer{
                                localFs: fsTest.fs,
-                               cfg:     DeployConfig{MaxDeletes: -1}, bucket: fsTest.bucket,
+                               cfg:     deployconfig.DeployConfig{MaxDeletes: -1}, bucket: fsTest.bucket,
                                mediaTypes: media.DefaultTypes,
                        }
 
@@ -848,11 +849,11 @@ func TestIncludeExcludeRemoteDelete(t *testing.T) {
                        }
 
                        // Second sync
-                       tgt := &Target{
+                       tgt := &deployconfig.Target{
                                Include: test.Include,
                                Exclude: test.Exclude,
                        }
-                       if err := tgt.parseIncludeExclude(); err != nil {
+                       if err := tgt.ParseIncludeExclude(); err != nil {
                                t.Error(err)
                        }
                        deployer.target = tgt
@@ -882,7 +883,7 @@ func TestCompression(t *testing.T) {
                        deployer := &Deployer{
                                localFs:    test.fs,
                                bucket:     test.bucket,
-                               cfg:        DeployConfig{MaxDeletes: -1, Matchers: []*Matcher{{Pattern: ".*", Gzip: true, re: regexp.MustCompile(".*")}}},
+                               cfg:        deployconfig.DeployConfig{MaxDeletes: -1, Matchers: []*deployconfig.Matcher{{Pattern: ".*", Gzip: true, Re: regexp.MustCompile(".*")}}},
                                mediaTypes: media.DefaultTypes,
                        }
 
@@ -937,7 +938,7 @@ func TestMatching(t *testing.T) {
                        deployer := &Deployer{
                                localFs:    test.fs,
                                bucket:     test.bucket,
-                               cfg:        DeployConfig{MaxDeletes: -1, Matchers: []*Matcher{{Pattern: "^subdir/aaa$", Force: true, re: regexp.MustCompile("^subdir/aaa$")}}},
+                               cfg:        deployconfig.DeployConfig{MaxDeletes: -1, Matchers: []*deployconfig.Matcher{{Pattern: "^subdir/aaa$", Force: true, Re: regexp.MustCompile("^subdir/aaa$")}}},
                                mediaTypes: media.DefaultTypes,
                        }
 
@@ -962,7 +963,7 @@ func TestMatching(t *testing.T) {
                        }
 
                        // Repeat with a matcher that should now match 3 files.
-                       deployer.cfg.Matchers = []*Matcher{{Pattern: "aaa", Force: true, re: regexp.MustCompile("aaa")}}
+                       deployer.cfg.Matchers = []*deployconfig.Matcher{{Pattern: "aaa", Force: true, Re: regexp.MustCompile("aaa")}}
                        if err := deployer.Deploy(ctx); err != nil {
                                t.Errorf("no-op deploy with triple force matcher: %v", err)
                        }
diff --git a/deploy/deployconfig/deployConfig.go b/deploy/deployconfig/deployConfig.go
new file mode 100644 (file)
index 0000000..b5b8973
--- /dev/null
@@ -0,0 +1,174 @@
+// Copyright 2024 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 deployconfig
+
+import (
+       "errors"
+       "fmt"
+       "regexp"
+
+       "github.com/gobwas/glob"
+       "github.com/gohugoio/hugo/config"
+       hglob "github.com/gohugoio/hugo/hugofs/glob"
+       "github.com/mitchellh/mapstructure"
+)
+
+const DeploymentConfigKey = "deployment"
+
+// DeployConfig is the complete configuration for deployment.
+type DeployConfig struct {
+       Targets  []*Target
+       Matchers []*Matcher
+       Order    []string
+
+       // Usually set via flags.
+       // Target deployment Name; defaults to the first one.
+       Target string
+       // Show a confirm prompt before deploying.
+       Confirm bool
+       // DryRun will try the deployment without any remote changes.
+       DryRun bool
+       // Force will re-upload all files.
+       Force bool
+       // Invalidate the CDN cache listed in the deployment target.
+       InvalidateCDN bool
+       // MaxDeletes is the maximum number of files to delete.
+       MaxDeletes int
+       // Number of concurrent workers to use when uploading files.
+       Workers int
+
+       Ordering []*regexp.Regexp `json:"-"` // compiled Order
+}
+
+type Target struct {
+       Name string
+       URL  string
+
+       CloudFrontDistributionID string
+
+       // GoogleCloudCDNOrigin specifies the Google Cloud project and CDN origin to
+       // invalidate when deploying this target.  It is specified as <project>/<origin>.
+       GoogleCloudCDNOrigin string
+
+       // Optional patterns of files to include/exclude for this target.
+       // Parsed using github.com/gobwas/glob.
+       Include string
+       Exclude string
+
+       // Parsed versions of Include/Exclude.
+       IncludeGlob glob.Glob `json:"-"`
+       ExcludeGlob glob.Glob `json:"-"`
+}
+
+func (tgt *Target) ParseIncludeExclude() error {
+       var err error
+       if tgt.Include != "" {
+               tgt.IncludeGlob, err = hglob.GetGlob(tgt.Include)
+               if err != nil {
+                       return fmt.Errorf("invalid deployment.target.include %q: %v", tgt.Include, err)
+               }
+       }
+       if tgt.Exclude != "" {
+               tgt.ExcludeGlob, err = hglob.GetGlob(tgt.Exclude)
+               if err != nil {
+                       return fmt.Errorf("invalid deployment.target.exclude %q: %v", tgt.Exclude, err)
+               }
+       }
+       return nil
+}
+
+// Matcher represents configuration to be applied to files whose paths match
+// a specified pattern.
+type Matcher struct {
+       // Pattern is the string pattern to match against paths.
+       // Matching is done against paths converted to use / as the path separator.
+       Pattern string
+
+       // CacheControl specifies caching attributes to use when serving the blob.
+       // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
+       CacheControl string
+
+       // ContentEncoding specifies the encoding used for the blob's content, if any.
+       // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding
+       ContentEncoding string
+
+       // ContentType specifies the MIME type of the blob being written.
+       // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type
+       ContentType string
+
+       // Gzip determines whether the file should be gzipped before upload.
+       // If so, the ContentEncoding field will automatically be set to "gzip".
+       Gzip bool
+
+       // Force indicates that matching files should be re-uploaded. Useful when
+       // other route-determined metadata (e.g., ContentType) has changed.
+       Force bool
+
+       // Re is Pattern compiled.
+       Re *regexp.Regexp `json:"-"`
+}
+
+func (m *Matcher) Matches(path string) bool {
+       return m.Re.MatchString(path)
+}
+
+var DefaultConfig = DeployConfig{
+       Workers:       10,
+       InvalidateCDN: true,
+       MaxDeletes:    256,
+}
+
+// DecodeConfig creates a config from a given Hugo configuration.
+func DecodeConfig(cfg config.Provider) (DeployConfig, error) {
+       dcfg := DefaultConfig
+
+       if !cfg.IsSet(DeploymentConfigKey) {
+               return dcfg, nil
+       }
+       if err := mapstructure.WeakDecode(cfg.GetStringMap(DeploymentConfigKey), &dcfg); err != nil {
+               return dcfg, err
+       }
+
+       if dcfg.Workers <= 0 {
+               dcfg.Workers = 10
+       }
+
+       for _, tgt := range dcfg.Targets {
+               if *tgt == (Target{}) {
+                       return dcfg, errors.New("empty deployment target")
+               }
+               if err := tgt.ParseIncludeExclude(); err != nil {
+                       return dcfg, err
+               }
+       }
+       var err error
+       for _, m := range dcfg.Matchers {
+               if *m == (Matcher{}) {
+                       return dcfg, errors.New("empty deployment matcher")
+               }
+               m.Re, err = regexp.Compile(m.Pattern)
+               if err != nil {
+                       return dcfg, fmt.Errorf("invalid deployment.matchers.pattern: %v", err)
+               }
+       }
+       for _, o := range dcfg.Order {
+               re, err := regexp.Compile(o)
+               if err != nil {
+                       return dcfg, fmt.Errorf("invalid deployment.orderings.pattern: %v", err)
+               }
+               dcfg.Ordering = append(dcfg.Ordering, re)
+       }
+
+       return dcfg, nil
+}
diff --git a/deploy/deployconfig/deployConfig_test.go b/deploy/deployconfig/deployConfig_test.go
new file mode 100644 (file)
index 0000000..0b92a5c
--- /dev/null
@@ -0,0 +1,199 @@
+// Copyright 2024 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.
+
+//go:build !nodeploy
+// +build !nodeploy
+
+package deployconfig
+
+import (
+       "fmt"
+       "testing"
+
+       qt "github.com/frankban/quicktest"
+       "github.com/gohugoio/hugo/config"
+)
+
+func TestDecodeConfigFromTOML(t *testing.T) {
+       c := qt.New(t)
+
+       tomlConfig := `
+
+someOtherValue = "foo"
+
+[deployment]
+
+order = ["o1", "o2"]
+
+# All lowercase.
+[[deployment.targets]]
+name = "name0"
+url = "url0"
+cloudfrontdistributionid = "cdn0"
+include = "*.html"
+
+# All uppercase.
+[[deployment.targets]]
+NAME = "name1"
+URL = "url1"
+CLOUDFRONTDISTRIBUTIONID = "cdn1"
+INCLUDE = "*.jpg"
+
+# Camelcase.
+[[deployment.targets]]
+name = "name2"
+url = "url2"
+cloudFrontDistributionID = "cdn2"
+exclude = "*.png"
+
+# All lowercase.
+[[deployment.matchers]]
+pattern = "^pattern0$"
+cachecontrol = "cachecontrol0"
+contentencoding = "contentencoding0"
+contenttype = "contenttype0"
+
+# All uppercase.
+[[deployment.matchers]]
+PATTERN = "^pattern1$"
+CACHECONTROL = "cachecontrol1"
+CONTENTENCODING = "contentencoding1"
+CONTENTTYPE = "contenttype1"
+GZIP = true
+FORCE = true
+
+# Camelcase.
+[[deployment.matchers]]
+pattern = "^pattern2$"
+cacheControl = "cachecontrol2"
+contentEncoding = "contentencoding2"
+contentType = "contenttype2"
+gzip = true
+force = true
+`
+       cfg, err := config.FromConfigString(tomlConfig, "toml")
+       c.Assert(err, qt.IsNil)
+
+       dcfg, err := DecodeConfig(cfg)
+       c.Assert(err, qt.IsNil)
+
+       // Order.
+       c.Assert(len(dcfg.Order), qt.Equals, 2)
+       c.Assert(dcfg.Order[0], qt.Equals, "o1")
+       c.Assert(dcfg.Order[1], qt.Equals, "o2")
+       c.Assert(len(dcfg.Ordering), qt.Equals, 2)
+
+       // Targets.
+       c.Assert(len(dcfg.Targets), qt.Equals, 3)
+       wantInclude := []string{"*.html", "*.jpg", ""}
+       wantExclude := []string{"", "", "*.png"}
+       for i := 0; i < 3; i++ {
+               tgt := dcfg.Targets[i]
+               c.Assert(tgt.Name, qt.Equals, fmt.Sprintf("name%d", i))
+               c.Assert(tgt.URL, qt.Equals, fmt.Sprintf("url%d", i))
+               c.Assert(tgt.CloudFrontDistributionID, qt.Equals, fmt.Sprintf("cdn%d", i))
+               c.Assert(tgt.Include, qt.Equals, wantInclude[i])
+               if wantInclude[i] != "" {
+                       c.Assert(tgt.IncludeGlob, qt.Not(qt.IsNil))
+               }
+               c.Assert(tgt.Exclude, qt.Equals, wantExclude[i])
+               if wantExclude[i] != "" {
+                       c.Assert(tgt.ExcludeGlob, qt.Not(qt.IsNil))
+               }
+       }
+
+       // Matchers.
+       c.Assert(len(dcfg.Matchers), qt.Equals, 3)
+       for i := 0; i < 3; i++ {
+               m := dcfg.Matchers[i]
+               c.Assert(m.Pattern, qt.Equals, fmt.Sprintf("^pattern%d$", i))
+               c.Assert(m.Re, qt.Not(qt.IsNil))
+               c.Assert(m.CacheControl, qt.Equals, fmt.Sprintf("cachecontrol%d", i))
+               c.Assert(m.ContentEncoding, qt.Equals, fmt.Sprintf("contentencoding%d", i))
+               c.Assert(m.ContentType, qt.Equals, fmt.Sprintf("contenttype%d", i))
+               c.Assert(m.Gzip, qt.Equals, i != 0)
+               c.Assert(m.Force, qt.Equals, i != 0)
+       }
+}
+
+func TestInvalidOrderingPattern(t *testing.T) {
+       c := qt.New(t)
+
+       tomlConfig := `
+
+someOtherValue = "foo"
+
+[deployment]
+order = ["["]  # invalid regular expression
+`
+       cfg, err := config.FromConfigString(tomlConfig, "toml")
+       c.Assert(err, qt.IsNil)
+
+       _, err = DecodeConfig(cfg)
+       c.Assert(err, qt.Not(qt.IsNil))
+}
+
+func TestInvalidMatcherPattern(t *testing.T) {
+       c := qt.New(t)
+
+       tomlConfig := `
+
+someOtherValue = "foo"
+
+[deployment]
+[[deployment.matchers]]
+Pattern = "["  # invalid regular expression
+`
+       cfg, err := config.FromConfigString(tomlConfig, "toml")
+       c.Assert(err, qt.IsNil)
+
+       _, err = DecodeConfig(cfg)
+       c.Assert(err, qt.Not(qt.IsNil))
+}
+
+func TestDecodeConfigDefault(t *testing.T) {
+       c := qt.New(t)
+
+       dcfg, err := DecodeConfig(config.New())
+       c.Assert(err, qt.IsNil)
+       c.Assert(len(dcfg.Targets), qt.Equals, 0)
+       c.Assert(len(dcfg.Matchers), qt.Equals, 0)
+}
+
+func TestEmptyTarget(t *testing.T) {
+       c := qt.New(t)
+
+       tomlConfig := `
+[deployment]
+[[deployment.targets]]
+`
+       cfg, err := config.FromConfigString(tomlConfig, "toml")
+       c.Assert(err, qt.IsNil)
+
+       _, err = DecodeConfig(cfg)
+       c.Assert(err, qt.Not(qt.IsNil))
+}
+
+func TestEmptyMatcher(t *testing.T) {
+       c := qt.New(t)
+
+       tomlConfig := `
+[deployment]
+[[deployment.matchers]]
+`
+       cfg, err := config.FromConfigString(tomlConfig, "toml")
+       c.Assert(err, qt.IsNil)
+
+       _, err = DecodeConfig(cfg)
+       c.Assert(err, qt.Not(qt.IsNil))
+}