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"
)
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")
},
}
}
"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"
// <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:"-"`
"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"
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
},
},
"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"
)
// 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
"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"
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
// 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 {
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]
// 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 {
// 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
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
// 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 {
}
// Find the first matching matcher (if any).
- var m *Matcher
+ var m *deployconfig.Matcher
for _, cur := range matchers {
if cur.Matches(slashpath) {
m = cur
+++ /dev/null
-// 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
-}
+++ /dev/null
-// 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))
-}
"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"
{
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{
tests := []struct {
Description string
Path string
- Matcher *Matcher
+ Matcher *deployconfig.Matcher
MediaTypesConfig map[string]any
WantContent []byte
WantSize int64
{
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[:],
{
Description: "ContentEncoding from matcher",
Path: "foo.txt",
- Matcher: &Matcher{ContentEncoding: "foobar"},
+ Matcher: &deployconfig.Matcher{ContentEncoding: "foobar"},
WantContent: contentBytes,
WantSize: contentLen,
WantMD5: contentMD5[:],
{
Description: "ContentType from matcher",
Path: "foo.txt",
- Matcher: &Matcher{ContentType: "foo/bar"},
+ Matcher: &deployconfig.Matcher{ContentType: "foo/bar"},
WantContent: contentBytes,
WantSize: contentLen,
WantMD5: contentMD5[:],
{
Description: "gzipped content",
Path: "foo.txt",
- Matcher: &Matcher{Gzip: true},
+ Matcher: &deployconfig.Matcher{Gzip: true},
WantContent: gzBytes,
WantSize: gzLen,
WantMD5: gzMD5[:],
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.
localFs: test.fs,
bucket: test.bucket,
mediaTypes: media.DefaultTypes,
- cfg: DeployConfig{MaxDeletes: -1},
+ cfg: deployconfig.DeployConfig{MaxDeletes: -1},
}
// Sync remote with local.
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,
}
}
deployer := &Deployer{
localFs: fsTest.fs,
- cfg: DeployConfig{MaxDeletes: -1}, bucket: fsTest.bucket,
+ cfg: deployconfig.DeployConfig{MaxDeletes: -1}, bucket: fsTest.bucket,
mediaTypes: media.DefaultTypes,
}
}
// 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
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,
}
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,
}
}
// 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)
}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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))
+}