diff options
Diffstat (limited to 'deploy/deploy_test.go')
-rw-r--r-- | deploy/deploy_test.go | 1031 |
1 files changed, 1031 insertions, 0 deletions
diff --git a/deploy/deploy_test.go b/deploy/deploy_test.go new file mode 100644 index 000000000..0ca1b3fac --- /dev/null +++ b/deploy/deploy_test.go @@ -0,0 +1,1031 @@ +// 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 ( + "bytes" + "compress/gzip" + "context" + "crypto/md5" + "fmt" + "io" + "io/ioutil" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/spf13/afero" + "gocloud.dev/blob" + "gocloud.dev/blob/fileblob" + "gocloud.dev/blob/memblob" +) + +func TestFindDiffs(t *testing.T) { + hash1 := []byte("hash 1") + hash2 := []byte("hash 2") + makeLocal := func(path string, size int64, hash []byte) *localFile { + return &localFile{NativePath: path, SlashPath: filepath.ToSlash(path), UploadSize: size, md5: hash} + } + makeRemote := func(path string, size int64, hash []byte) *blob.ListObject { + return &blob.ListObject{Key: path, Size: size, MD5: hash} + } + + tests := []struct { + Description string + Local []*localFile + Remote []*blob.ListObject + Force bool + WantUpdates []*fileToUpload + WantDeletes []string + }{ + { + Description: "empty -> no diffs", + }, + { + Description: "local == remote -> no diffs", + Local: []*localFile{ + makeLocal("aaa", 1, hash1), + makeLocal("bbb", 2, hash1), + makeLocal("ccc", 3, hash2), + }, + Remote: []*blob.ListObject{ + makeRemote("aaa", 1, hash1), + makeRemote("bbb", 2, hash1), + makeRemote("ccc", 3, hash2), + }, + }, + { + Description: "local w/ separators == remote -> no diffs", + Local: []*localFile{ + makeLocal(filepath.Join("aaa", "aaa"), 1, hash1), + makeLocal(filepath.Join("bbb", "bbb"), 2, hash1), + makeLocal(filepath.Join("ccc", "ccc"), 3, hash2), + }, + Remote: []*blob.ListObject{ + makeRemote("aaa/aaa", 1, hash1), + makeRemote("bbb/bbb", 2, hash1), + makeRemote("ccc/ccc", 3, hash2), + }, + }, + { + Description: "local == remote with force flag true -> diffs", + Local: []*localFile{ + makeLocal("aaa", 1, hash1), + makeLocal("bbb", 2, hash1), + makeLocal("ccc", 3, hash2), + }, + Remote: []*blob.ListObject{ + makeRemote("aaa", 1, hash1), + makeRemote("bbb", 2, hash1), + makeRemote("ccc", 3, hash2), + }, + Force: true, + WantUpdates: []*fileToUpload{ + {makeLocal("aaa", 1, nil), reasonForce}, + {makeLocal("bbb", 2, nil), reasonForce}, + {makeLocal("ccc", 3, nil), reasonForce}, + }, + }, + { + Description: "local == remote with route.Force true -> diffs", + Local: []*localFile{ + {NativePath: "aaa", SlashPath: "aaa", UploadSize: 1, matcher: &matcher{Force: true}, md5: hash1}, + makeLocal("bbb", 2, hash1), + }, + Remote: []*blob.ListObject{ + makeRemote("aaa", 1, hash1), + makeRemote("bbb", 2, hash1), + }, + WantUpdates: []*fileToUpload{ + {makeLocal("aaa", 1, nil), reasonForce}, + }, + }, + { + Description: "extra local file -> upload", + Local: []*localFile{ + makeLocal("aaa", 1, hash1), + makeLocal("bbb", 2, hash2), + }, + Remote: []*blob.ListObject{ + makeRemote("aaa", 1, hash1), + }, + WantUpdates: []*fileToUpload{ + {makeLocal("bbb", 2, nil), reasonNotFound}, + }, + }, + { + Description: "extra remote file -> delete", + Local: []*localFile{ + makeLocal("aaa", 1, hash1), + }, + Remote: []*blob.ListObject{ + makeRemote("aaa", 1, hash1), + makeRemote("bbb", 2, hash2), + }, + WantDeletes: []string{"bbb"}, + }, + { + Description: "diffs in size or md5 -> upload", + Local: []*localFile{ + makeLocal("aaa", 1, hash1), + makeLocal("bbb", 2, hash1), + makeLocal("ccc", 1, hash2), + }, + Remote: []*blob.ListObject{ + makeRemote("aaa", 1, nil), + makeRemote("bbb", 1, hash1), + makeRemote("ccc", 1, hash1), + }, + WantUpdates: []*fileToUpload{ + {makeLocal("aaa", 1, nil), reasonMD5Missing}, + {makeLocal("bbb", 2, nil), reasonSize}, + {makeLocal("ccc", 1, nil), reasonMD5Differs}, + }, + }, + { + Description: "mix of updates and deletes", + Local: []*localFile{ + makeLocal("same", 1, hash1), + makeLocal("updated", 2, hash1), + makeLocal("updated2", 1, hash2), + makeLocal("new", 1, hash1), + makeLocal("new2", 2, hash2), + }, + Remote: []*blob.ListObject{ + makeRemote("same", 1, hash1), + makeRemote("updated", 1, hash1), + makeRemote("updated2", 1, hash1), + makeRemote("stale", 1, hash1), + makeRemote("stale2", 1, hash1), + }, + WantUpdates: []*fileToUpload{ + {makeLocal("new", 1, nil), reasonNotFound}, + {makeLocal("new2", 2, nil), reasonNotFound}, + {makeLocal("updated", 2, nil), reasonSize}, + {makeLocal("updated2", 1, nil), reasonMD5Differs}, + }, + WantDeletes: []string{"stale", "stale2"}, + }, + } + + for _, tc := range tests { + t.Run(tc.Description, func(t *testing.T) { + local := map[string]*localFile{} + for _, l := range tc.Local { + local[l.SlashPath] = l + } + remote := map[string]*blob.ListObject{} + for _, r := range tc.Remote { + remote[r.Key] = r + } + gotUpdates, gotDeletes := findDiffs(local, remote, tc.Force) + gotUpdates = applyOrdering(nil, gotUpdates)[0] + sort.Slice(gotDeletes, func(i, j int) bool { return gotDeletes[i] < gotDeletes[j] }) + if diff := cmp.Diff(gotUpdates, tc.WantUpdates, cmpopts.IgnoreUnexported(localFile{})); diff != "" { + t.Errorf("updates differ:\n%s", diff) + } + if diff := cmp.Diff(gotDeletes, tc.WantDeletes); diff != "" { + t.Errorf("deletes differ:\n%s", diff) + } + }) + } +} + +func TestWalkLocal(t *testing.T) { + tests := map[string]struct { + Given []string + Expect []string + }{ + "Empty": { + Given: []string{}, + Expect: []string{}, + }, + "Normal": { + Given: []string{"file.txt", "normal_dir/file.txt"}, + Expect: []string{"file.txt", "normal_dir/file.txt"}, + }, + "Hidden": { + Given: []string{"file.txt", ".hidden_dir/file.txt", "normal_dir/file.txt"}, + Expect: []string{"file.txt", "normal_dir/file.txt"}, + }, + "Well Known": { + Given: []string{"file.txt", ".hidden_dir/file.txt", ".well-known/file.txt"}, + Expect: []string{"file.txt", ".well-known/file.txt"}, + }, + } + + for desc, tc := range tests { + t.Run(desc, func(t *testing.T) { + fs := afero.NewMemMapFs() + for _, name := range tc.Given { + dir, _ := path.Split(name) + if dir != "" { + if err := fs.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + } + if fd, err := fs.Create(name); err != nil { + t.Fatal(err) + } else { + fd.Close() + } + } + if got, err := walkLocal(fs, nil, nil, nil); err != nil { + t.Fatal(err) + } else { + expect := map[string]interface{}{} + for _, path := range tc.Expect { + if _, ok := got[path]; !ok { + t.Errorf("expected %q in results, but was not found", path) + } + expect[path] = nil + } + for path := range got { + if _, ok := expect[path]; !ok { + t.Errorf("got %q in results unexpectedly", path) + } + } + } + }) + } +} + +func TestLocalFile(t *testing.T) { + const ( + content = "hello world!" + ) + contentBytes := []byte(content) + contentLen := int64(len(contentBytes)) + contentMD5 := md5.Sum(contentBytes) + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + if _, err := gz.Write(contentBytes); err != nil { + t.Fatal(err) + } + gz.Close() + gzBytes := buf.Bytes() + gzLen := int64(len(gzBytes)) + gzMD5 := md5.Sum(gzBytes) + + tests := []struct { + Description string + Path string + Matcher *matcher + WantContent []byte + WantSize int64 + WantMD5 []byte + WantContentType string // empty string is always OK, since content type detection is OS-specific + WantCacheControl string + WantContentEncoding string + }{ + { + Description: "file with no suffix", + Path: "foo", + WantContent: contentBytes, + WantSize: contentLen, + WantMD5: contentMD5[:], + }, + { + Description: "file with .txt suffix", + Path: "foo.txt", + WantContent: contentBytes, + WantSize: contentLen, + WantMD5: contentMD5[:], + }, + { + Description: "CacheControl from matcher", + Path: "foo.txt", + Matcher: &matcher{CacheControl: "max-age=630720000"}, + WantContent: contentBytes, + WantSize: contentLen, + WantMD5: contentMD5[:], + WantCacheControl: "max-age=630720000", + }, + { + Description: "ContentEncoding from matcher", + Path: "foo.txt", + Matcher: &matcher{ContentEncoding: "foobar"}, + WantContent: contentBytes, + WantSize: contentLen, + WantMD5: contentMD5[:], + WantContentEncoding: "foobar", + }, + { + Description: "ContentType from matcher", + Path: "foo.txt", + Matcher: &matcher{ContentType: "foo/bar"}, + WantContent: contentBytes, + WantSize: contentLen, + WantMD5: contentMD5[:], + WantContentType: "foo/bar", + }, + { + Description: "gzipped content", + Path: "foo.txt", + Matcher: &matcher{Gzip: true}, + WantContent: gzBytes, + WantSize: gzLen, + WantMD5: gzMD5[:], + WantContentEncoding: "gzip", + }, + } + + for _, tc := range tests { + t.Run(tc.Description, func(t *testing.T) { + fs := new(afero.MemMapFs) + if err := afero.WriteFile(fs, tc.Path, []byte(content), os.ModePerm); err != nil { + t.Fatal(err) + } + lf, err := newLocalFile(fs, tc.Path, filepath.ToSlash(tc.Path), tc.Matcher) + if err != nil { + t.Fatal(err) + } + if got := lf.UploadSize; got != tc.WantSize { + t.Errorf("got size %d want %d", got, tc.WantSize) + } + if got := lf.MD5(); !bytes.Equal(got, tc.WantMD5) { + t.Errorf("got MD5 %x want %x", got, tc.WantMD5) + } + if got := lf.CacheControl(); got != tc.WantCacheControl { + t.Errorf("got CacheControl %q want %q", got, tc.WantCacheControl) + } + if got := lf.ContentEncoding(); got != tc.WantContentEncoding { + t.Errorf("got ContentEncoding %q want %q", got, tc.WantContentEncoding) + } + if tc.WantContentType != "" { + if got := lf.ContentType(); got != tc.WantContentType { + t.Errorf("got ContentType %q want %q", got, tc.WantContentType) + } + } + // Verify the reader last to ensure the previous operations don't + // interfere with it. + r, err := lf.Reader() + if err != nil { + t.Fatal(err) + } + gotContent, err := ioutil.ReadAll(r) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(gotContent, tc.WantContent) { + t.Errorf("got content %q want %q", string(gotContent), string(tc.WantContent)) + } + r.Close() + // Verify we can read again. + r, err = lf.Reader() + if err != nil { + t.Fatal(err) + } + gotContent, err = ioutil.ReadAll(r) + if err != nil { + t.Fatal(err) + } + r.Close() + if !bytes.Equal(gotContent, tc.WantContent) { + t.Errorf("got content %q want %q", string(gotContent), string(tc.WantContent)) + } + }) + } +} + +func TestOrdering(t *testing.T) { + tests := []struct { + Description string + Uploads []string + Ordering []*regexp.Regexp + Want [][]string + }{ + { + Description: "empty", + Want: [][]string{nil}, + }, + { + Description: "no ordering", + Uploads: []string{"c", "b", "a", "d"}, + Want: [][]string{{"a", "b", "c", "d"}}, + }, + { + Description: "one ordering", + Uploads: []string{"db", "c", "b", "a", "da"}, + Ordering: []*regexp.Regexp{regexp.MustCompile("^d")}, + Want: [][]string{{"da", "db"}, {"a", "b", "c"}}, + }, + { + Description: "two orderings", + Uploads: []string{"db", "c", "b", "a", "da"}, + Ordering: []*regexp.Regexp{ + regexp.MustCompile("^d"), + regexp.MustCompile("^b"), + }, + Want: [][]string{{"da", "db"}, {"b"}, {"a", "c"}}, + }, + } + + for _, tc := range tests { + t.Run(tc.Description, func(t *testing.T) { + uploads := make([]*fileToUpload, len(tc.Uploads)) + for i, u := range tc.Uploads { + uploads[i] = &fileToUpload{Local: &localFile{SlashPath: u}} + } + gotUploads := applyOrdering(tc.Ordering, uploads) + var got [][]string + for _, subslice := range gotUploads { + var gotsubslice []string + for _, u := range subslice { + gotsubslice = append(gotsubslice, u.Local.SlashPath) + } + got = append(got, gotsubslice) + } + if diff := cmp.Diff(got, tc.Want); diff != "" { + t.Error(diff) + } + }) + } +} + +type fileData struct { + Name string // name of the file + Contents string // contents of the file +} + +// initLocalFs initializes fs with some test files. +func initLocalFs(ctx context.Context, fs afero.Fs) ([]*fileData, error) { + // The initial local filesystem. + local := []*fileData{ + {"aaa", "aaa"}, + {"bbb", "bbb"}, + {"subdir/aaa", "subdir-aaa"}, + {"subdir/nested/aaa", "subdir-nested-aaa"}, + {"subdir2/bbb", "subdir2-bbb"}, + } + if err := writeFiles(fs, local); err != nil { + return nil, err + } + return local, nil +} + +// fsTest represents an (afero.FS, Go CDK blob.Bucket) against which end-to-end +// tests can be run. +type fsTest struct { + name string + fs afero.Fs + bucket *blob.Bucket +} + +// initFsTests initializes a pair of tests for end-to-end test: +// 1. An in-memory afero.Fs paired with an in-memory Go CDK bucket. +// 2. A filesystem-based afero.Fs paired with an filesystem-based Go CDK bucket. +// It returns the pair of tests and a cleanup function. +func initFsTests() ([]*fsTest, func(), error) { + tmpfsdir, err := ioutil.TempDir("", "fs") + if err != nil { + return nil, nil, err + } + tmpbucketdir, err := ioutil.TempDir("", "bucket") + if err != nil { + return nil, nil, err + } + + memfs := afero.NewMemMapFs() + membucket := memblob.OpenBucket(nil) + + filefs := afero.NewBasePathFs(afero.NewOsFs(), tmpfsdir) + filebucket, err := fileblob.OpenBucket(tmpbucketdir, nil) + if err != nil { + return nil, nil, err + } + + tests := []*fsTest{ + {"mem", memfs, membucket}, + {"file", filefs, filebucket}, + } + cleanup := func() { + membucket.Close() + filebucket.Close() + os.RemoveAll(tmpfsdir) + os.RemoveAll(tmpbucketdir) + } + return tests, cleanup, nil +} + +// TestEndToEndSync verifies that basic adds, updates, and deletes are working +// correctly. +func TestEndToEndSync(t *testing.T) { + ctx := context.Background() + tests, cleanup, err := initFsTests() + if err != nil { + t.Fatal(err) + } + defer cleanup() + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + local, err := initLocalFs(ctx, test.fs) + if err != nil { + t.Fatal(err) + } + deployer := &Deployer{ + localFs: test.fs, + maxDeletes: -1, + bucket: test.bucket, + } + + // Initial deployment should sync remote with local. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("initial deploy: failed: %v", err) + } + wantSummary := deploySummary{NumLocal: 5, NumRemote: 0, NumUploads: 5, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("initial deploy: got %v, want %v", deployer.summary, wantSummary) + } + if diff, err := verifyRemote(ctx, deployer.bucket, local); err != nil { + t.Errorf("initial deploy: failed to verify remote: %v", err) + } else if diff != "" { + t.Errorf("initial deploy: remote snapshot doesn't match expected:\n%v", diff) + } + + // A repeat deployment shouldn't change anything. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("no-op deploy: %v", err) + } + wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 0, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("no-op deploy: got %v, want %v", deployer.summary, wantSummary) + } + + // Make some changes to the local filesystem: + // 1. Modify file [0]. + // 2. Delete file [1]. + // 3. Add a new file (sorted last). + updatefd := local[0] + updatefd.Contents = "new contents" + deletefd := local[1] + local = append(local[:1], local[2:]...) // removing deleted [1] + newfd := &fileData{"zzz", "zzz"} + local = append(local, newfd) + if err := writeFiles(test.fs, []*fileData{updatefd, newfd}); err != nil { + t.Fatal(err) + } + if err := test.fs.Remove(deletefd.Name); err != nil { + t.Fatal(err) + } + + // A deployment should apply those 3 changes. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("deploy after changes: failed: %v", err) + } + wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 2, NumDeletes: 1} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("deploy after changes: got %v, want %v", deployer.summary, wantSummary) + } + if diff, err := verifyRemote(ctx, deployer.bucket, local); err != nil { + t.Errorf("deploy after changes: failed to verify remote: %v", err) + } else if diff != "" { + t.Errorf("deploy after changes: remote snapshot doesn't match expected:\n%v", diff) + } + + // Again, a repeat deployment shouldn't change anything. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("no-op deploy: %v", err) + } + wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 0, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("no-op deploy: got %v, want %v", deployer.summary, wantSummary) + } + }) + } +} + +// TestMaxDeletes verifies that the "maxDeletes" flag is working correctly. +func TestMaxDeletes(t *testing.T) { + ctx := context.Background() + tests, cleanup, err := initFsTests() + if err != nil { + t.Fatal(err) + } + defer cleanup() + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + local, err := initLocalFs(ctx, test.fs) + if err != nil { + t.Fatal(err) + } + deployer := &Deployer{ + localFs: test.fs, + maxDeletes: -1, + bucket: test.bucket, + } + + // Sync remote with local. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("initial deploy: failed: %v", err) + } + wantSummary := deploySummary{NumLocal: 5, NumRemote: 0, NumUploads: 5, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("initial deploy: got %v, want %v", deployer.summary, wantSummary) + } + + // Delete two files, [1] and [2]. + if err := test.fs.Remove(local[1].Name); err != nil { + t.Fatal(err) + } + if err := test.fs.Remove(local[2].Name); err != nil { + t.Fatal(err) + } + + // A deployment with maxDeletes=0 shouldn't change anything. + deployer.maxDeletes = 0 + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("deploy failed: %v", err) + } + wantSummary = deploySummary{NumLocal: 3, NumRemote: 5, NumUploads: 0, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("deploy: got %v, want %v", deployer.summary, wantSummary) + } + + // A deployment with maxDeletes=1 shouldn't change anything either. + deployer.maxDeletes = 1 + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("deploy failed: %v", err) + } + wantSummary = deploySummary{NumLocal: 3, NumRemote: 5, NumUploads: 0, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("deploy: got %v, want %v", deployer.summary, wantSummary) + } + + // A deployment with maxDeletes=2 should make the changes. + deployer.maxDeletes = 2 + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("deploy failed: %v", err) + } + wantSummary = deploySummary{NumLocal: 3, NumRemote: 5, NumUploads: 0, NumDeletes: 2} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("deploy: got %v, want %v", deployer.summary, wantSummary) + } + + // Delete two more files, [0] and [3]. + if err := test.fs.Remove(local[0].Name); err != nil { + t.Fatal(err) + } + if err := test.fs.Remove(local[3].Name); err != nil { + t.Fatal(err) + } + + // A deployment with maxDeletes=-1 should make the changes. + deployer.maxDeletes = -1 + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("deploy failed: %v", err) + } + wantSummary = deploySummary{NumLocal: 1, NumRemote: 3, NumUploads: 0, NumDeletes: 2} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("deploy: got %v, want %v", deployer.summary, wantSummary) + } + }) + } +} + +// TestIncludeExclude verifies that the include/exclude options for targets work. +func TestIncludeExclude(t *testing.T) { + ctx := context.Background() + + tests := []struct { + Include string + Exclude string + Want deploySummary + }{ + { + Want: deploySummary{NumLocal: 5, NumUploads: 5}, + }, + { + Include: "**aaa", + Want: deploySummary{NumLocal: 3, NumUploads: 3}, + }, + { + Include: "**bbb", + Want: deploySummary{NumLocal: 2, NumUploads: 2}, + }, + { + Include: "aaa", + Want: deploySummary{NumLocal: 1, NumUploads: 1}, + }, + { + Exclude: "**aaa", + Want: deploySummary{NumLocal: 2, NumUploads: 2}, + }, + { + Exclude: "**bbb", + Want: deploySummary{NumLocal: 3, NumUploads: 3}, + }, + { + Exclude: "aaa", + Want: deploySummary{NumLocal: 4, NumUploads: 4}, + }, + { + Include: "**aaa", + Exclude: "**nested**", + Want: deploySummary{NumLocal: 2, NumUploads: 2}, + }, + } + for _, test := range tests { + t.Run(fmt.Sprintf("include %q exclude %q", test.Include, test.Exclude), func(t *testing.T) { + fsTests, cleanup, err := initFsTests() + if err != nil { + t.Fatal(err) + } + defer cleanup() + fsTest := fsTests[1] // just do file-based test + + _, err = initLocalFs(ctx, fsTest.fs) + if err != nil { + t.Fatal(err) + } + tgt := &target{ + Include: test.Include, + Exclude: test.Exclude, + } + if err := tgt.parseIncludeExclude(); err != nil { + t.Error(err) + } + deployer := &Deployer{ + localFs: fsTest.fs, + maxDeletes: -1, + bucket: fsTest.bucket, + target: tgt, + } + + // Sync remote with local. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("deploy: failed: %v", err) + } + if !cmp.Equal(deployer.summary, test.Want) { + t.Errorf("deploy: got %v, want %v", deployer.summary, test.Want) + } + }) + } +} + +// TestIncludeExcludeRemoteDelete verifies deleted local files that don't match include/exclude patterns +// are not deleted on the remote. +func TestIncludeExcludeRemoteDelete(t *testing.T) { + ctx := context.Background() + + tests := []struct { + Include string + Exclude string + Want deploySummary + }{ + { + Want: deploySummary{NumLocal: 3, NumRemote: 5, NumUploads: 0, NumDeletes: 2}, + }, + { + Include: "**aaa", + Want: deploySummary{NumLocal: 2, NumRemote: 3, NumUploads: 0, NumDeletes: 1}, + }, + { + Include: "subdir/**", + Want: deploySummary{NumLocal: 1, NumRemote: 2, NumUploads: 0, NumDeletes: 1}, + }, + { + Exclude: "**bbb", + Want: deploySummary{NumLocal: 2, NumRemote: 3, NumUploads: 0, NumDeletes: 1}, + }, + { + Exclude: "bbb", + Want: deploySummary{NumLocal: 3, NumRemote: 4, NumUploads: 0, NumDeletes: 1}, + }, + } + for _, test := range tests { + t.Run(fmt.Sprintf("include %q exclude %q", test.Include, test.Exclude), func(t *testing.T) { + fsTests, cleanup, err := initFsTests() + if err != nil { + t.Fatal(err) + } + defer cleanup() + fsTest := fsTests[1] // just do file-based test + + local, err := initLocalFs(ctx, fsTest.fs) + if err != nil { + t.Fatal(err) + } + deployer := &Deployer{ + localFs: fsTest.fs, + maxDeletes: -1, + bucket: fsTest.bucket, + } + + // Initial sync to get the files on the remote + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("deploy: failed: %v", err) + } + + // Delete two files, [1] and [2]. + if err := fsTest.fs.Remove(local[1].Name); err != nil { + t.Fatal(err) + } + if err := fsTest.fs.Remove(local[2].Name); err != nil { + t.Fatal(err) + } + + // Second sync + tgt := &target{ + Include: test.Include, + Exclude: test.Exclude, + } + if err := tgt.parseIncludeExclude(); err != nil { + t.Error(err) + } + deployer.target = tgt + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("deploy: failed: %v", err) + } + + if !cmp.Equal(deployer.summary, test.Want) { + t.Errorf("deploy: got %v, want %v", deployer.summary, test.Want) + } + }) + } +} + +// TestCompression verifies that gzip compression works correctly. +// In particular, MD5 hashes must be of the compressed content. +func TestCompression(t *testing.T) { + ctx := context.Background() + tests, cleanup, err := initFsTests() + if err != nil { + t.Fatal(err) + } + defer cleanup() + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + local, err := initLocalFs(ctx, test.fs) + if err != nil { + t.Fatal(err) + } + deployer := &Deployer{ + localFs: test.fs, + bucket: test.bucket, + matchers: []*matcher{{Pattern: ".*", Gzip: true, re: regexp.MustCompile(".*")}}, + } + + // Initial deployment should sync remote with local. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("initial deploy: failed: %v", err) + } + wantSummary := deploySummary{NumLocal: 5, NumRemote: 0, NumUploads: 5, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("initial deploy: got %v, want %v", deployer.summary, wantSummary) + } + + // A repeat deployment shouldn't change anything. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("no-op deploy: %v", err) + } + wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 0, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("no-op deploy: got %v, want %v", deployer.summary, wantSummary) + } + + // Make an update to the local filesystem, on [1]. + updatefd := local[1] + updatefd.Contents = "new contents" + if err := writeFiles(test.fs, []*fileData{updatefd}); err != nil { + t.Fatal(err) + } + + // A deployment should apply the changes. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("deploy after changes: failed: %v", err) + } + wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 1, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("deploy after changes: got %v, want %v", deployer.summary, wantSummary) + } + }) + } +} + +// TestMatching verifies that matchers match correctly, and that the Force +// attribute for matcher works. +func TestMatching(t *testing.T) { + ctx := context.Background() + tests, cleanup, err := initFsTests() + if err != nil { + t.Fatal(err) + } + defer cleanup() + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := initLocalFs(ctx, test.fs) + if err != nil { + t.Fatal(err) + } + deployer := &Deployer{ + localFs: test.fs, + bucket: test.bucket, + matchers: []*matcher{{Pattern: "^subdir/aaa$", Force: true, re: regexp.MustCompile("^subdir/aaa$")}}, + } + + // Initial deployment to sync remote with local. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("initial deploy: failed: %v", err) + } + wantSummary := deploySummary{NumLocal: 5, NumRemote: 0, NumUploads: 5, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("initial deploy: got %v, want %v", deployer.summary, wantSummary) + } + + // A repeat deployment should upload a single file, the one that matched the Force matcher. + // Note that matching happens based on the ToSlash form, so this matches + // even on Windows. + if err := deployer.Deploy(ctx); err != nil { + t.Errorf("no-op deploy with single force matcher: %v", err) + } + wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 1, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("no-op deploy with single force matcher: got %v, want %v", deployer.summary, wantSummary) + } + + // Repeat with a matcher that should now match 3 files. + deployer.matchers = []*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) + } + wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 3, NumDeletes: 0} + if !cmp.Equal(deployer.summary, wantSummary) { + t.Errorf("no-op deploy with triple force matcher: got %v, want %v", deployer.summary, wantSummary) + } + }) + } +} + +// writeFiles writes the files in fds to fd. +func writeFiles(fs afero.Fs, fds []*fileData) error { + for _, fd := range fds { + dir := path.Dir(fd.Name) + if dir != "." { + err := fs.MkdirAll(dir, os.ModePerm) + if err != nil { + return err + } + } + f, err := fs.Create(fd.Name) + if err != nil { + return err + } + defer f.Close() + _, err = f.WriteString(fd.Contents) + if err != nil { + return err + } + } + return nil +} + +// verifyRemote that the current contents of bucket matches local. +// It returns an empty string if the contents matched, and a non-empty string +// capturing the diff if they didn't. +func verifyRemote(ctx context.Context, bucket *blob.Bucket, local []*fileData) (string, error) { + var cur []*fileData + iter := bucket.List(nil) + for { + obj, err := iter.Next(ctx) + if err == io.EOF { + break + } + if err != nil { + return "", err + } + contents, err := bucket.ReadAll(ctx, obj.Key) + if err != nil { + return "", err + } + cur = append(cur, &fileData{obj.Key, string(contents)}) + } + if cmp.Equal(cur, local) { + return "", nil + } + diff := "got: \n" + for _, f := range cur { + diff += fmt.Sprintf(" %s: %s\n", f.Name, f.Contents) + } + diff += "want: \n" + for _, f := range local { + diff += fmt.Sprintf(" %s: %s\n", f.Name, f.Contents) + } + return diff, nil +} |