github.com/kovansky/hugo@v0.92.3-0.20220224232819-63076e4ff19f/deploy/deploy.go (about)

     1  // Copyright 2019 The Hugo Authors. All rights reserved.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  // http://www.apache.org/licenses/LICENSE-2.0
     7  //
     8  // Unless required by applicable law or agreed to in writing, software
     9  // distributed under the License is distributed on an "AS IS" BASIS,
    10  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    11  // See the License for the specific language governing permissions and
    12  // limitations under the License.
    13  
    14  // +build !nodeploy
    15  
    16  package deploy
    17  
    18  import (
    19  	"bytes"
    20  	"compress/gzip"
    21  	"context"
    22  	"crypto/md5"
    23  	"fmt"
    24  	"io"
    25  	"io/ioutil"
    26  	"mime"
    27  	"os"
    28  	"path/filepath"
    29  	"regexp"
    30  	"runtime"
    31  	"sort"
    32  	"strings"
    33  	"sync"
    34  
    35  	"github.com/dustin/go-humanize"
    36  	"github.com/gobwas/glob"
    37  	"github.com/gohugoio/hugo/config"
    38  	"github.com/gohugoio/hugo/media"
    39  	"github.com/pkg/errors"
    40  	"github.com/spf13/afero"
    41  	jww "github.com/spf13/jwalterweatherman"
    42  	"golang.org/x/text/unicode/norm"
    43  
    44  	"gocloud.dev/blob"
    45  	_ "gocloud.dev/blob/fileblob" // import
    46  	_ "gocloud.dev/blob/gcsblob"  // import
    47  	_ "gocloud.dev/blob/s3blob"   // import
    48  	"gocloud.dev/gcerrors"
    49  )
    50  
    51  // Deployer supports deploying the site to target cloud providers.
    52  type Deployer struct {
    53  	localFs afero.Fs
    54  	bucket  *blob.Bucket
    55  
    56  	target        *target          // the target to deploy to
    57  	matchers      []*matcher       // matchers to apply to uploaded files
    58  	mediaTypes    media.Types      // Hugo's MediaType to guess ContentType
    59  	ordering      []*regexp.Regexp // orders uploads
    60  	quiet         bool             // true reduces STDOUT
    61  	confirm       bool             // true enables confirmation before making changes
    62  	dryRun        bool             // true skips conformations and prints changes instead of applying them
    63  	force         bool             // true forces upload of all files
    64  	invalidateCDN bool             // true enables invalidate CDN cache (if possible)
    65  	maxDeletes    int              // caps the # of files to delete; -1 to disable
    66  
    67  	// For tests...
    68  	summary deploySummary // summary of latest Deploy results
    69  }
    70  
    71  type deploySummary struct {
    72  	NumLocal, NumRemote, NumUploads, NumDeletes int
    73  }
    74  
    75  // New constructs a new *Deployer.
    76  func New(cfg config.Provider, localFs afero.Fs) (*Deployer, error) {
    77  	targetName := cfg.GetString("target")
    78  
    79  	// Load the [deployment] section of the config.
    80  	dcfg, err := decodeConfig(cfg)
    81  	if err != nil {
    82  		return nil, err
    83  	}
    84  
    85  	if len(dcfg.Targets) == 0 {
    86  		return nil, errors.New("no deployment targets found")
    87  	}
    88  
    89  	// Find the target to deploy to.
    90  	var tgt *target
    91  	if targetName == "" {
    92  		// Default to the first target.
    93  		tgt = dcfg.Targets[0]
    94  	} else {
    95  		for _, t := range dcfg.Targets {
    96  			if t.Name == targetName {
    97  				tgt = t
    98  			}
    99  		}
   100  		if tgt == nil {
   101  			return nil, fmt.Errorf("deployment target %q not found", targetName)
   102  		}
   103  	}
   104  
   105  	return &Deployer{
   106  		localFs:       localFs,
   107  		target:        tgt,
   108  		matchers:      dcfg.Matchers,
   109  		ordering:      dcfg.ordering,
   110  		mediaTypes:    dcfg.mediaTypes,
   111  		quiet:         cfg.GetBool("quiet"),
   112  		confirm:       cfg.GetBool("confirm"),
   113  		dryRun:        cfg.GetBool("dryRun"),
   114  		force:         cfg.GetBool("force"),
   115  		invalidateCDN: cfg.GetBool("invalidateCDN"),
   116  		maxDeletes:    cfg.GetInt("maxDeletes"),
   117  	}, nil
   118  }
   119  
   120  func (d *Deployer) openBucket(ctx context.Context) (*blob.Bucket, error) {
   121  	if d.bucket != nil {
   122  		return d.bucket, nil
   123  	}
   124  	jww.FEEDBACK.Printf("Deploying to target %q (%s)\n", d.target.Name, d.target.URL)
   125  	return blob.OpenBucket(ctx, d.target.URL)
   126  }
   127  
   128  // Deploy deploys the site to a target.
   129  func (d *Deployer) Deploy(ctx context.Context) error {
   130  	bucket, err := d.openBucket(ctx)
   131  	if err != nil {
   132  		return err
   133  	}
   134  
   135  	// Load local files from the source directory.
   136  	var include, exclude glob.Glob
   137  	if d.target != nil {
   138  		include, exclude = d.target.includeGlob, d.target.excludeGlob
   139  	}
   140  	local, err := walkLocal(d.localFs, d.matchers, include, exclude, d.mediaTypes)
   141  	if err != nil {
   142  		return err
   143  	}
   144  	jww.INFO.Printf("Found %d local files.\n", len(local))
   145  	d.summary.NumLocal = len(local)
   146  
   147  	// Load remote files from the target.
   148  	remote, err := walkRemote(ctx, bucket, include, exclude)
   149  	if err != nil {
   150  		return err
   151  	}
   152  	jww.INFO.Printf("Found %d remote files.\n", len(remote))
   153  	d.summary.NumRemote = len(remote)
   154  
   155  	// Diff local vs remote to see what changes need to be applied.
   156  	uploads, deletes := findDiffs(local, remote, d.force)
   157  	d.summary.NumUploads = len(uploads)
   158  	d.summary.NumDeletes = len(deletes)
   159  	if len(uploads)+len(deletes) == 0 {
   160  		if !d.quiet {
   161  			jww.FEEDBACK.Println("No changes required.")
   162  		}
   163  		return nil
   164  	}
   165  	if !d.quiet {
   166  		jww.FEEDBACK.Println(summarizeChanges(uploads, deletes))
   167  	}
   168  
   169  	// Ask for confirmation before proceeding.
   170  	if d.confirm && !d.dryRun {
   171  		fmt.Printf("Continue? (Y/n) ")
   172  		var confirm string
   173  		if _, err := fmt.Scanln(&confirm); err != nil {
   174  			return err
   175  		}
   176  		if confirm != "" && confirm[0] != 'y' && confirm[0] != 'Y' {
   177  			return errors.New("aborted")
   178  		}
   179  	}
   180  
   181  	// Order the uploads. They are organized in groups; all uploads in a group
   182  	// must be complete before moving on to the next group.
   183  	uploadGroups := applyOrdering(d.ordering, uploads)
   184  
   185  	// Apply the changes in parallel, using an inverted worker
   186  	// pool (https://www.youtube.com/watch?v=5zXAHh5tJqQ&t=26m58s).
   187  	// sem prevents more than nParallel concurrent goroutines.
   188  	const nParallel = 10
   189  	var errs []error
   190  	var errMu sync.Mutex // protects errs
   191  
   192  	for _, uploads := range uploadGroups {
   193  		// Short-circuit for an empty group.
   194  		if len(uploads) == 0 {
   195  			continue
   196  		}
   197  
   198  		// Within the group, apply uploads in parallel.
   199  		sem := make(chan struct{}, nParallel)
   200  		for _, upload := range uploads {
   201  			if d.dryRun {
   202  				if !d.quiet {
   203  					jww.FEEDBACK.Printf("[DRY RUN] Would upload: %v\n", upload)
   204  				}
   205  				continue
   206  			}
   207  
   208  			sem <- struct{}{}
   209  			go func(upload *fileToUpload) {
   210  				if err := doSingleUpload(ctx, bucket, upload); err != nil {
   211  					errMu.Lock()
   212  					defer errMu.Unlock()
   213  					errs = append(errs, err)
   214  				}
   215  				<-sem
   216  			}(upload)
   217  		}
   218  		// Wait for all uploads in the group to finish.
   219  		for n := nParallel; n > 0; n-- {
   220  			sem <- struct{}{}
   221  		}
   222  	}
   223  
   224  	if d.maxDeletes != -1 && len(deletes) > d.maxDeletes {
   225  		jww.WARN.Printf("Skipping %d deletes because it is more than --maxDeletes (%d). If this is expected, set --maxDeletes to a larger number, or -1 to disable this check.\n", len(deletes), d.maxDeletes)
   226  		d.summary.NumDeletes = 0
   227  	} else {
   228  		// Apply deletes in parallel.
   229  		sort.Slice(deletes, func(i, j int) bool { return deletes[i] < deletes[j] })
   230  		sem := make(chan struct{}, nParallel)
   231  		for _, del := range deletes {
   232  			if d.dryRun {
   233  				if !d.quiet {
   234  					jww.FEEDBACK.Printf("[DRY RUN] Would delete %s\n", del)
   235  				}
   236  				continue
   237  			}
   238  			sem <- struct{}{}
   239  			go func(del string) {
   240  				jww.INFO.Printf("Deleting %s...\n", del)
   241  				if err := bucket.Delete(ctx, del); err != nil {
   242  					if gcerrors.Code(err) == gcerrors.NotFound {
   243  						jww.WARN.Printf("Failed to delete %q because it wasn't found: %v", del, err)
   244  					} else {
   245  						errMu.Lock()
   246  						defer errMu.Unlock()
   247  						errs = append(errs, err)
   248  					}
   249  				}
   250  				<-sem
   251  			}(del)
   252  		}
   253  		// Wait for all deletes to finish.
   254  		for n := nParallel; n > 0; n-- {
   255  			sem <- struct{}{}
   256  		}
   257  	}
   258  	if len(errs) > 0 {
   259  		if !d.quiet {
   260  			jww.FEEDBACK.Printf("Encountered %d errors.\n", len(errs))
   261  		}
   262  		return errs[0]
   263  	}
   264  	if !d.quiet {
   265  		jww.FEEDBACK.Println("Success!")
   266  	}
   267  
   268  	if d.invalidateCDN {
   269  		if d.target.CloudFrontDistributionID != "" {
   270  			if d.dryRun {
   271  				if !d.quiet {
   272  					jww.FEEDBACK.Printf("[DRY RUN] Would invalidate CloudFront CDN with ID %s\n", d.target.CloudFrontDistributionID)
   273  				}
   274  			} else {
   275  				jww.FEEDBACK.Println("Invalidating CloudFront CDN...")
   276  				if err := InvalidateCloudFront(ctx, d.target.CloudFrontDistributionID); err != nil {
   277  					jww.FEEDBACK.Printf("Failed to invalidate CloudFront CDN: %v\n", err)
   278  					return err
   279  				}
   280  			}
   281  		}
   282  		if d.target.GoogleCloudCDNOrigin != "" {
   283  			if d.dryRun {
   284  				if !d.quiet {
   285  					jww.FEEDBACK.Printf("[DRY RUN] Would invalidate Google Cloud CDN with origin %s\n", d.target.GoogleCloudCDNOrigin)
   286  				}
   287  			} else {
   288  				jww.FEEDBACK.Println("Invalidating Google Cloud CDN...")
   289  				if err := InvalidateGoogleCloudCDN(ctx, d.target.GoogleCloudCDNOrigin); err != nil {
   290  					jww.FEEDBACK.Printf("Failed to invalidate Google Cloud CDN: %v\n", err)
   291  					return err
   292  				}
   293  			}
   294  		}
   295  		jww.FEEDBACK.Println("Success!")
   296  	}
   297  	return nil
   298  }
   299  
   300  // summarizeChanges creates a text description of the proposed changes.
   301  func summarizeChanges(uploads []*fileToUpload, deletes []string) string {
   302  	uploadSize := int64(0)
   303  	for _, u := range uploads {
   304  		uploadSize += u.Local.UploadSize
   305  	}
   306  	return fmt.Sprintf("Identified %d file(s) to upload, totaling %s, and %d file(s) to delete.", len(uploads), humanize.Bytes(uint64(uploadSize)), len(deletes))
   307  }
   308  
   309  // doSingleUpload executes a single file upload.
   310  func doSingleUpload(ctx context.Context, bucket *blob.Bucket, upload *fileToUpload) error {
   311  	jww.INFO.Printf("Uploading %v...\n", upload)
   312  	opts := &blob.WriterOptions{
   313  		CacheControl:    upload.Local.CacheControl(),
   314  		ContentEncoding: upload.Local.ContentEncoding(),
   315  		ContentType:     upload.Local.ContentType(),
   316  	}
   317  	w, err := bucket.NewWriter(ctx, upload.Local.SlashPath, opts)
   318  	if err != nil {
   319  		return err
   320  	}
   321  	r, err := upload.Local.Reader()
   322  	if err != nil {
   323  		return err
   324  	}
   325  	defer r.Close()
   326  	_, err = io.Copy(w, r)
   327  	if err != nil {
   328  		return err
   329  	}
   330  	if err := w.Close(); err != nil {
   331  		return err
   332  	}
   333  	return nil
   334  }
   335  
   336  // localFile represents a local file from the source. Use newLocalFile to
   337  // construct one.
   338  type localFile struct {
   339  	// NativePath is the native path to the file (using file.Separator).
   340  	NativePath string
   341  	// SlashPath is NativePath converted to use /.
   342  	SlashPath string
   343  	// UploadSize is the size of the content to be uploaded. It may not
   344  	// be the same as the local file size if the content will be
   345  	// gzipped before upload.
   346  	UploadSize int64
   347  
   348  	fs         afero.Fs
   349  	matcher    *matcher
   350  	md5        []byte       // cache
   351  	gzipped    bytes.Buffer // cached of gzipped contents if gzipping
   352  	mediaTypes media.Types
   353  }
   354  
   355  // newLocalFile initializes a *localFile.
   356  func newLocalFile(fs afero.Fs, nativePath, slashpath string, m *matcher, mt media.Types) (*localFile, error) {
   357  	f, err := fs.Open(nativePath)
   358  	if err != nil {
   359  		return nil, err
   360  	}
   361  	defer f.Close()
   362  	lf := &localFile{
   363  		NativePath: nativePath,
   364  		SlashPath:  slashpath,
   365  		fs:         fs,
   366  		matcher:    m,
   367  		mediaTypes: mt,
   368  	}
   369  	if m != nil && m.Gzip {
   370  		// We're going to gzip the content. Do it once now, and cache the result
   371  		// in gzipped. The UploadSize is the size of the gzipped content.
   372  		gz := gzip.NewWriter(&lf.gzipped)
   373  		if _, err := io.Copy(gz, f); err != nil {
   374  			return nil, err
   375  		}
   376  		if err := gz.Close(); err != nil {
   377  			return nil, err
   378  		}
   379  		lf.UploadSize = int64(lf.gzipped.Len())
   380  	} else {
   381  		// Raw content. Just get the UploadSize.
   382  		info, err := f.Stat()
   383  		if err != nil {
   384  			return nil, err
   385  		}
   386  		lf.UploadSize = info.Size()
   387  	}
   388  	return lf, nil
   389  }
   390  
   391  // Reader returns an io.ReadCloser for reading the content to be uploaded.
   392  // The caller must call Close on the returned ReaderCloser.
   393  // The reader content may not be the same as the local file content due to
   394  // gzipping.
   395  func (lf *localFile) Reader() (io.ReadCloser, error) {
   396  	if lf.matcher != nil && lf.matcher.Gzip {
   397  		// We've got the gzipped contents cached in gzipped.
   398  		// Note: we can't use lf.gzipped directly as a Reader, since we it discards
   399  		// data after it is read, and we may read it more than once.
   400  		return ioutil.NopCloser(bytes.NewReader(lf.gzipped.Bytes())), nil
   401  	}
   402  	// Not expected to fail since we did it successfully earlier in newLocalFile,
   403  	// but could happen due to changes in the underlying filesystem.
   404  	return lf.fs.Open(lf.NativePath)
   405  }
   406  
   407  // CacheControl returns the Cache-Control header to use for lf, based on the
   408  // first matching matcher (if any).
   409  func (lf *localFile) CacheControl() string {
   410  	if lf.matcher == nil {
   411  		return ""
   412  	}
   413  	return lf.matcher.CacheControl
   414  }
   415  
   416  // ContentEncoding returns the Content-Encoding header to use for lf, based
   417  // on the matcher's Content-Encoding and Gzip fields.
   418  func (lf *localFile) ContentEncoding() string {
   419  	if lf.matcher == nil {
   420  		return ""
   421  	}
   422  	if lf.matcher.Gzip {
   423  		return "gzip"
   424  	}
   425  	return lf.matcher.ContentEncoding
   426  }
   427  
   428  // ContentType returns the Content-Type header to use for lf.
   429  // It first checks if there's a Content-Type header configured via a matching
   430  // matcher; if not, it tries to generate one based on the filename extension.
   431  // If this fails, the Content-Type will be the empty string. In this case, Go
   432  // Cloud will automatically try to infer a Content-Type based on the file
   433  // content.
   434  func (lf *localFile) ContentType() string {
   435  	if lf.matcher != nil && lf.matcher.ContentType != "" {
   436  		return lf.matcher.ContentType
   437  	}
   438  
   439  	ext := filepath.Ext(lf.NativePath)
   440  	if mimeType, _, found := lf.mediaTypes.GetFirstBySuffix(strings.TrimPrefix(ext, ".")); found {
   441  		return mimeType.Type()
   442  	}
   443  
   444  	return mime.TypeByExtension(ext)
   445  }
   446  
   447  // Force returns true if the file should be forced to re-upload based on the
   448  // matching matcher.
   449  func (lf *localFile) Force() bool {
   450  	return lf.matcher != nil && lf.matcher.Force
   451  }
   452  
   453  // MD5 returns an MD5 hash of the content to be uploaded.
   454  func (lf *localFile) MD5() []byte {
   455  	if len(lf.md5) > 0 {
   456  		return lf.md5
   457  	}
   458  	h := md5.New()
   459  	r, err := lf.Reader()
   460  	if err != nil {
   461  		return nil
   462  	}
   463  	defer r.Close()
   464  	if _, err := io.Copy(h, r); err != nil {
   465  		return nil
   466  	}
   467  	lf.md5 = h.Sum(nil)
   468  	return lf.md5
   469  }
   470  
   471  // knownHiddenDirectory checks if the specified name is a well known
   472  // hidden directory.
   473  func knownHiddenDirectory(name string) bool {
   474  	knownDirectories := []string{
   475  		".well-known",
   476  	}
   477  
   478  	for _, dir := range knownDirectories {
   479  		if name == dir {
   480  			return true
   481  		}
   482  	}
   483  	return false
   484  }
   485  
   486  // walkLocal walks the source directory and returns a flat list of files,
   487  // using localFile.SlashPath as the map keys.
   488  func walkLocal(fs afero.Fs, matchers []*matcher, include, exclude glob.Glob, mediaTypes media.Types) (map[string]*localFile, error) {
   489  	retval := map[string]*localFile{}
   490  	err := afero.Walk(fs, "", func(path string, info os.FileInfo, err error) error {
   491  		if err != nil {
   492  			return err
   493  		}
   494  		if info.IsDir() {
   495  			// Skip hidden directories.
   496  			if path != "" && strings.HasPrefix(info.Name(), ".") {
   497  				// Except for specific hidden directories
   498  				if !knownHiddenDirectory(info.Name()) {
   499  					return filepath.SkipDir
   500  				}
   501  			}
   502  			return nil
   503  		}
   504  
   505  		// .DS_Store is an internal MacOS attribute file; skip it.
   506  		if info.Name() == ".DS_Store" {
   507  			return nil
   508  		}
   509  
   510  		// When a file system is HFS+, its filepath is in NFD form.
   511  		if runtime.GOOS == "darwin" {
   512  			path = norm.NFC.String(path)
   513  		}
   514  
   515  		// Check include/exclude matchers.
   516  		slashpath := filepath.ToSlash(path)
   517  		if include != nil && !include.Match(slashpath) {
   518  			jww.INFO.Printf("  dropping %q due to include\n", slashpath)
   519  			return nil
   520  		}
   521  		if exclude != nil && exclude.Match(slashpath) {
   522  			jww.INFO.Printf("  dropping %q due to exclude\n", slashpath)
   523  			return nil
   524  		}
   525  
   526  		// Find the first matching matcher (if any).
   527  		var m *matcher
   528  		for _, cur := range matchers {
   529  			if cur.Matches(slashpath) {
   530  				m = cur
   531  				break
   532  			}
   533  		}
   534  		lf, err := newLocalFile(fs, path, slashpath, m, mediaTypes)
   535  		if err != nil {
   536  			return err
   537  		}
   538  		retval[lf.SlashPath] = lf
   539  		return nil
   540  	})
   541  	if err != nil {
   542  		return nil, err
   543  	}
   544  	return retval, nil
   545  }
   546  
   547  // walkRemote walks the target bucket and returns a flat list.
   548  func walkRemote(ctx context.Context, bucket *blob.Bucket, include, exclude glob.Glob) (map[string]*blob.ListObject, error) {
   549  	retval := map[string]*blob.ListObject{}
   550  	iter := bucket.List(nil)
   551  	for {
   552  		obj, err := iter.Next(ctx)
   553  		if err == io.EOF {
   554  			break
   555  		}
   556  		if err != nil {
   557  			return nil, err
   558  		}
   559  		// Check include/exclude matchers.
   560  		if include != nil && !include.Match(obj.Key) {
   561  			jww.INFO.Printf("  remote dropping %q due to include\n", obj.Key)
   562  			continue
   563  		}
   564  		if exclude != nil && exclude.Match(obj.Key) {
   565  			jww.INFO.Printf("  remote dropping %q due to exclude\n", obj.Key)
   566  			continue
   567  		}
   568  		// If the remote didn't give us an MD5, compute one.
   569  		// This can happen for some providers (e.g., fileblob, which uses the
   570  		// local filesystem), but not for the most common Cloud providers
   571  		// (S3, GCS, Azure). Although, it can happen for S3 if the blob was uploaded
   572  		// via a multi-part upload.
   573  		// Although it's unfortunate to have to read the file, it's likely better
   574  		// than assuming a delta and re-uploading it.
   575  		if len(obj.MD5) == 0 {
   576  			r, err := bucket.NewReader(ctx, obj.Key, nil)
   577  			if err == nil {
   578  				h := md5.New()
   579  				if _, err := io.Copy(h, r); err == nil {
   580  					obj.MD5 = h.Sum(nil)
   581  				}
   582  				r.Close()
   583  			}
   584  		}
   585  		retval[obj.Key] = obj
   586  	}
   587  	return retval, nil
   588  }
   589  
   590  // uploadReason is an enum of reasons why a file must be uploaded.
   591  type uploadReason string
   592  
   593  const (
   594  	reasonUnknown    uploadReason = "unknown"
   595  	reasonNotFound   uploadReason = "not found at target"
   596  	reasonForce      uploadReason = "--force"
   597  	reasonSize       uploadReason = "size differs"
   598  	reasonMD5Differs uploadReason = "md5 differs"
   599  	reasonMD5Missing uploadReason = "remote md5 missing"
   600  )
   601  
   602  // fileToUpload represents a single local file that should be uploaded to
   603  // the target.
   604  type fileToUpload struct {
   605  	Local  *localFile
   606  	Reason uploadReason
   607  }
   608  
   609  func (u *fileToUpload) String() string {
   610  	details := []string{humanize.Bytes(uint64(u.Local.UploadSize))}
   611  	if s := u.Local.CacheControl(); s != "" {
   612  		details = append(details, fmt.Sprintf("Cache-Control: %q", s))
   613  	}
   614  	if s := u.Local.ContentEncoding(); s != "" {
   615  		details = append(details, fmt.Sprintf("Content-Encoding: %q", s))
   616  	}
   617  	if s := u.Local.ContentType(); s != "" {
   618  		details = append(details, fmt.Sprintf("Content-Type: %q", s))
   619  	}
   620  	return fmt.Sprintf("%s (%s): %v", u.Local.SlashPath, strings.Join(details, ", "), u.Reason)
   621  }
   622  
   623  // findDiffs diffs localFiles vs remoteFiles to see what changes should be
   624  // applied to the remote target. It returns a slice of *fileToUpload and a
   625  // slice of paths for files to delete.
   626  func findDiffs(localFiles map[string]*localFile, remoteFiles map[string]*blob.ListObject, force bool) ([]*fileToUpload, []string) {
   627  	var uploads []*fileToUpload
   628  	var deletes []string
   629  
   630  	found := map[string]bool{}
   631  	for path, lf := range localFiles {
   632  		upload := false
   633  		reason := reasonUnknown
   634  
   635  		if remoteFile, ok := remoteFiles[path]; ok {
   636  			// The file exists in remote. Let's see if we need to upload it anyway.
   637  
   638  			// TODO: We don't register a diff if the metadata (e.g., Content-Type
   639  			// header) has changed. This would be difficult/expensive to detect; some
   640  			// providers return metadata along with their "List" result, but others
   641  			// (notably AWS S3) do not, so gocloud.dev's blob.Bucket doesn't expose
   642  			// it in the list result. It would require a separate request per blob
   643  			// to fetch. At least for now, we work around this by documenting it and
   644  			// providing a "force" flag (to re-upload everything) and a "force" bool
   645  			// per matcher (to re-upload all files in a matcher whose headers may have
   646  			// changed).
   647  			// Idea: extract a sample set of 1 file per extension + 1 file per matcher
   648  			// and check those files?
   649  			if force {
   650  				upload = true
   651  				reason = reasonForce
   652  			} else if lf.Force() {
   653  				upload = true
   654  				reason = reasonForce
   655  			} else if lf.UploadSize != remoteFile.Size {
   656  				upload = true
   657  				reason = reasonSize
   658  			} else if len(remoteFile.MD5) == 0 {
   659  				// This shouldn't happen unless the remote didn't give us an MD5 hash
   660  				// from List, AND we failed to compute one by reading the remote file.
   661  				// Default to considering the files different.
   662  				upload = true
   663  				reason = reasonMD5Missing
   664  			} else if !bytes.Equal(lf.MD5(), remoteFile.MD5) {
   665  				upload = true
   666  				reason = reasonMD5Differs
   667  			} else {
   668  				// Nope! Leave uploaded = false.
   669  			}
   670  			found[path] = true
   671  		} else {
   672  			// The file doesn't exist in remote.
   673  			upload = true
   674  			reason = reasonNotFound
   675  		}
   676  		if upload {
   677  			jww.DEBUG.Printf("%s needs to be uploaded: %v\n", path, reason)
   678  			uploads = append(uploads, &fileToUpload{lf, reason})
   679  		} else {
   680  			jww.DEBUG.Printf("%s exists at target and does not need to be uploaded", path)
   681  		}
   682  	}
   683  
   684  	// Remote files that weren't found locally should be deleted.
   685  	for path := range remoteFiles {
   686  		if !found[path] {
   687  			deletes = append(deletes, path)
   688  		}
   689  	}
   690  	return uploads, deletes
   691  }
   692  
   693  // applyOrdering returns an ordered slice of slices of uploads.
   694  //
   695  // The returned slice will have length len(ordering)+1.
   696  //
   697  // The subslice at index i, for i = 0 ... len(ordering)-1, will have all of the
   698  // uploads whose Local.SlashPath matched the regex at ordering[i] (but not any
   699  // previous ordering regex).
   700  // The subslice at index len(ordering) will have the remaining uploads that
   701  // didn't match any ordering regex.
   702  //
   703  // The subslices are sorted by Local.SlashPath.
   704  func applyOrdering(ordering []*regexp.Regexp, uploads []*fileToUpload) [][]*fileToUpload {
   705  	// Sort the whole slice by Local.SlashPath first.
   706  	sort.Slice(uploads, func(i, j int) bool { return uploads[i].Local.SlashPath < uploads[j].Local.SlashPath })
   707  
   708  	retval := make([][]*fileToUpload, len(ordering)+1)
   709  	for _, u := range uploads {
   710  		matched := false
   711  		for i, re := range ordering {
   712  			if re.MatchString(u.Local.SlashPath) {
   713  				retval[i] = append(retval[i], u)
   714  				matched = true
   715  				break
   716  			}
   717  		}
   718  		if !matched {
   719  			retval[len(ordering)] = append(retval[len(ordering)], u)
   720  		}
   721  	}
   722  	return retval
   723  }