github.com/kovansky/hugo@v0.92.3-0.20220224232819-63076e4ff19f/deploy/deploy_test.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 "os" 27 "path" 28 "path/filepath" 29 "regexp" 30 "sort" 31 "testing" 32 33 "github.com/gohugoio/hugo/media" 34 "github.com/google/go-cmp/cmp" 35 "github.com/google/go-cmp/cmp/cmpopts" 36 "github.com/spf13/afero" 37 "gocloud.dev/blob" 38 "gocloud.dev/blob/fileblob" 39 "gocloud.dev/blob/memblob" 40 ) 41 42 func TestFindDiffs(t *testing.T) { 43 hash1 := []byte("hash 1") 44 hash2 := []byte("hash 2") 45 makeLocal := func(path string, size int64, hash []byte) *localFile { 46 return &localFile{NativePath: path, SlashPath: filepath.ToSlash(path), UploadSize: size, md5: hash} 47 } 48 makeRemote := func(path string, size int64, hash []byte) *blob.ListObject { 49 return &blob.ListObject{Key: path, Size: size, MD5: hash} 50 } 51 52 tests := []struct { 53 Description string 54 Local []*localFile 55 Remote []*blob.ListObject 56 Force bool 57 WantUpdates []*fileToUpload 58 WantDeletes []string 59 }{ 60 { 61 Description: "empty -> no diffs", 62 }, 63 { 64 Description: "local == remote -> no diffs", 65 Local: []*localFile{ 66 makeLocal("aaa", 1, hash1), 67 makeLocal("bbb", 2, hash1), 68 makeLocal("ccc", 3, hash2), 69 }, 70 Remote: []*blob.ListObject{ 71 makeRemote("aaa", 1, hash1), 72 makeRemote("bbb", 2, hash1), 73 makeRemote("ccc", 3, hash2), 74 }, 75 }, 76 { 77 Description: "local w/ separators == remote -> no diffs", 78 Local: []*localFile{ 79 makeLocal(filepath.Join("aaa", "aaa"), 1, hash1), 80 makeLocal(filepath.Join("bbb", "bbb"), 2, hash1), 81 makeLocal(filepath.Join("ccc", "ccc"), 3, hash2), 82 }, 83 Remote: []*blob.ListObject{ 84 makeRemote("aaa/aaa", 1, hash1), 85 makeRemote("bbb/bbb", 2, hash1), 86 makeRemote("ccc/ccc", 3, hash2), 87 }, 88 }, 89 { 90 Description: "local == remote with force flag true -> diffs", 91 Local: []*localFile{ 92 makeLocal("aaa", 1, hash1), 93 makeLocal("bbb", 2, hash1), 94 makeLocal("ccc", 3, hash2), 95 }, 96 Remote: []*blob.ListObject{ 97 makeRemote("aaa", 1, hash1), 98 makeRemote("bbb", 2, hash1), 99 makeRemote("ccc", 3, hash2), 100 }, 101 Force: true, 102 WantUpdates: []*fileToUpload{ 103 {makeLocal("aaa", 1, nil), reasonForce}, 104 {makeLocal("bbb", 2, nil), reasonForce}, 105 {makeLocal("ccc", 3, nil), reasonForce}, 106 }, 107 }, 108 { 109 Description: "local == remote with route.Force true -> diffs", 110 Local: []*localFile{ 111 {NativePath: "aaa", SlashPath: "aaa", UploadSize: 1, matcher: &matcher{Force: true}, md5: hash1}, 112 makeLocal("bbb", 2, hash1), 113 }, 114 Remote: []*blob.ListObject{ 115 makeRemote("aaa", 1, hash1), 116 makeRemote("bbb", 2, hash1), 117 }, 118 WantUpdates: []*fileToUpload{ 119 {makeLocal("aaa", 1, nil), reasonForce}, 120 }, 121 }, 122 { 123 Description: "extra local file -> upload", 124 Local: []*localFile{ 125 makeLocal("aaa", 1, hash1), 126 makeLocal("bbb", 2, hash2), 127 }, 128 Remote: []*blob.ListObject{ 129 makeRemote("aaa", 1, hash1), 130 }, 131 WantUpdates: []*fileToUpload{ 132 {makeLocal("bbb", 2, nil), reasonNotFound}, 133 }, 134 }, 135 { 136 Description: "extra remote file -> delete", 137 Local: []*localFile{ 138 makeLocal("aaa", 1, hash1), 139 }, 140 Remote: []*blob.ListObject{ 141 makeRemote("aaa", 1, hash1), 142 makeRemote("bbb", 2, hash2), 143 }, 144 WantDeletes: []string{"bbb"}, 145 }, 146 { 147 Description: "diffs in size or md5 -> upload", 148 Local: []*localFile{ 149 makeLocal("aaa", 1, hash1), 150 makeLocal("bbb", 2, hash1), 151 makeLocal("ccc", 1, hash2), 152 }, 153 Remote: []*blob.ListObject{ 154 makeRemote("aaa", 1, nil), 155 makeRemote("bbb", 1, hash1), 156 makeRemote("ccc", 1, hash1), 157 }, 158 WantUpdates: []*fileToUpload{ 159 {makeLocal("aaa", 1, nil), reasonMD5Missing}, 160 {makeLocal("bbb", 2, nil), reasonSize}, 161 {makeLocal("ccc", 1, nil), reasonMD5Differs}, 162 }, 163 }, 164 { 165 Description: "mix of updates and deletes", 166 Local: []*localFile{ 167 makeLocal("same", 1, hash1), 168 makeLocal("updated", 2, hash1), 169 makeLocal("updated2", 1, hash2), 170 makeLocal("new", 1, hash1), 171 makeLocal("new2", 2, hash2), 172 }, 173 Remote: []*blob.ListObject{ 174 makeRemote("same", 1, hash1), 175 makeRemote("updated", 1, hash1), 176 makeRemote("updated2", 1, hash1), 177 makeRemote("stale", 1, hash1), 178 makeRemote("stale2", 1, hash1), 179 }, 180 WantUpdates: []*fileToUpload{ 181 {makeLocal("new", 1, nil), reasonNotFound}, 182 {makeLocal("new2", 2, nil), reasonNotFound}, 183 {makeLocal("updated", 2, nil), reasonSize}, 184 {makeLocal("updated2", 1, nil), reasonMD5Differs}, 185 }, 186 WantDeletes: []string{"stale", "stale2"}, 187 }, 188 } 189 190 for _, tc := range tests { 191 t.Run(tc.Description, func(t *testing.T) { 192 local := map[string]*localFile{} 193 for _, l := range tc.Local { 194 local[l.SlashPath] = l 195 } 196 remote := map[string]*blob.ListObject{} 197 for _, r := range tc.Remote { 198 remote[r.Key] = r 199 } 200 gotUpdates, gotDeletes := findDiffs(local, remote, tc.Force) 201 gotUpdates = applyOrdering(nil, gotUpdates)[0] 202 sort.Slice(gotDeletes, func(i, j int) bool { return gotDeletes[i] < gotDeletes[j] }) 203 if diff := cmp.Diff(gotUpdates, tc.WantUpdates, cmpopts.IgnoreUnexported(localFile{})); diff != "" { 204 t.Errorf("updates differ:\n%s", diff) 205 } 206 if diff := cmp.Diff(gotDeletes, tc.WantDeletes); diff != "" { 207 t.Errorf("deletes differ:\n%s", diff) 208 } 209 }) 210 } 211 } 212 213 func TestWalkLocal(t *testing.T) { 214 tests := map[string]struct { 215 Given []string 216 Expect []string 217 }{ 218 "Empty": { 219 Given: []string{}, 220 Expect: []string{}, 221 }, 222 "Normal": { 223 Given: []string{"file.txt", "normal_dir/file.txt"}, 224 Expect: []string{"file.txt", "normal_dir/file.txt"}, 225 }, 226 "Hidden": { 227 Given: []string{"file.txt", ".hidden_dir/file.txt", "normal_dir/file.txt"}, 228 Expect: []string{"file.txt", "normal_dir/file.txt"}, 229 }, 230 "Well Known": { 231 Given: []string{"file.txt", ".hidden_dir/file.txt", ".well-known/file.txt"}, 232 Expect: []string{"file.txt", ".well-known/file.txt"}, 233 }, 234 } 235 236 for desc, tc := range tests { 237 t.Run(desc, func(t *testing.T) { 238 fs := afero.NewMemMapFs() 239 for _, name := range tc.Given { 240 dir, _ := path.Split(name) 241 if dir != "" { 242 if err := fs.MkdirAll(dir, 0755); err != nil { 243 t.Fatal(err) 244 } 245 } 246 if fd, err := fs.Create(name); err != nil { 247 t.Fatal(err) 248 } else { 249 fd.Close() 250 } 251 } 252 if got, err := walkLocal(fs, nil, nil, nil, media.DefaultTypes); err != nil { 253 t.Fatal(err) 254 } else { 255 expect := map[string]interface{}{} 256 for _, path := range tc.Expect { 257 if _, ok := got[path]; !ok { 258 t.Errorf("expected %q in results, but was not found", path) 259 } 260 expect[path] = nil 261 } 262 for path := range got { 263 if _, ok := expect[path]; !ok { 264 t.Errorf("got %q in results unexpectedly", path) 265 } 266 } 267 } 268 }) 269 } 270 } 271 272 func TestLocalFile(t *testing.T) { 273 const ( 274 content = "hello world!" 275 ) 276 contentBytes := []byte(content) 277 contentLen := int64(len(contentBytes)) 278 contentMD5 := md5.Sum(contentBytes) 279 var buf bytes.Buffer 280 gz := gzip.NewWriter(&buf) 281 if _, err := gz.Write(contentBytes); err != nil { 282 t.Fatal(err) 283 } 284 gz.Close() 285 gzBytes := buf.Bytes() 286 gzLen := int64(len(gzBytes)) 287 gzMD5 := md5.Sum(gzBytes) 288 289 tests := []struct { 290 Description string 291 Path string 292 Matcher *matcher 293 MediaTypesConfig []map[string]interface{} 294 WantContent []byte 295 WantSize int64 296 WantMD5 []byte 297 WantContentType string // empty string is always OK, since content type detection is OS-specific 298 WantCacheControl string 299 WantContentEncoding string 300 }{ 301 { 302 Description: "file with no suffix", 303 Path: "foo", 304 WantContent: contentBytes, 305 WantSize: contentLen, 306 WantMD5: contentMD5[:], 307 }, 308 { 309 Description: "file with .txt suffix", 310 Path: "foo.txt", 311 WantContent: contentBytes, 312 WantSize: contentLen, 313 WantMD5: contentMD5[:], 314 }, 315 { 316 Description: "CacheControl from matcher", 317 Path: "foo.txt", 318 Matcher: &matcher{CacheControl: "max-age=630720000"}, 319 WantContent: contentBytes, 320 WantSize: contentLen, 321 WantMD5: contentMD5[:], 322 WantCacheControl: "max-age=630720000", 323 }, 324 { 325 Description: "ContentEncoding from matcher", 326 Path: "foo.txt", 327 Matcher: &matcher{ContentEncoding: "foobar"}, 328 WantContent: contentBytes, 329 WantSize: contentLen, 330 WantMD5: contentMD5[:], 331 WantContentEncoding: "foobar", 332 }, 333 { 334 Description: "ContentType from matcher", 335 Path: "foo.txt", 336 Matcher: &matcher{ContentType: "foo/bar"}, 337 WantContent: contentBytes, 338 WantSize: contentLen, 339 WantMD5: contentMD5[:], 340 WantContentType: "foo/bar", 341 }, 342 { 343 Description: "gzipped content", 344 Path: "foo.txt", 345 Matcher: &matcher{Gzip: true}, 346 WantContent: gzBytes, 347 WantSize: gzLen, 348 WantMD5: gzMD5[:], 349 WantContentEncoding: "gzip", 350 }, 351 { 352 Description: "Custom MediaType", 353 Path: "foo.hugo", 354 MediaTypesConfig: []map[string]interface{}{ 355 { 356 "hugo/custom": map[string]interface{}{ 357 "suffixes": []string{"hugo"}, 358 }, 359 }, 360 }, 361 WantContent: contentBytes, 362 WantSize: contentLen, 363 WantMD5: contentMD5[:], 364 WantContentType: "hugo/custom", 365 }, 366 } 367 368 for _, tc := range tests { 369 t.Run(tc.Description, func(t *testing.T) { 370 fs := new(afero.MemMapFs) 371 if err := afero.WriteFile(fs, tc.Path, []byte(content), os.ModePerm); err != nil { 372 t.Fatal(err) 373 } 374 mediaTypes := media.DefaultTypes 375 if len(tc.MediaTypesConfig) > 0 { 376 mt, err := media.DecodeTypes(tc.MediaTypesConfig...) 377 if err != nil { 378 t.Fatal(err) 379 } 380 mediaTypes = mt 381 } 382 lf, err := newLocalFile(fs, tc.Path, filepath.ToSlash(tc.Path), tc.Matcher, mediaTypes) 383 if err != nil { 384 t.Fatal(err) 385 } 386 if got := lf.UploadSize; got != tc.WantSize { 387 t.Errorf("got size %d want %d", got, tc.WantSize) 388 } 389 if got := lf.MD5(); !bytes.Equal(got, tc.WantMD5) { 390 t.Errorf("got MD5 %x want %x", got, tc.WantMD5) 391 } 392 if got := lf.CacheControl(); got != tc.WantCacheControl { 393 t.Errorf("got CacheControl %q want %q", got, tc.WantCacheControl) 394 } 395 if got := lf.ContentEncoding(); got != tc.WantContentEncoding { 396 t.Errorf("got ContentEncoding %q want %q", got, tc.WantContentEncoding) 397 } 398 if tc.WantContentType != "" { 399 if got := lf.ContentType(); got != tc.WantContentType { 400 t.Errorf("got ContentType %q want %q", got, tc.WantContentType) 401 } 402 } 403 // Verify the reader last to ensure the previous operations don't 404 // interfere with it. 405 r, err := lf.Reader() 406 if err != nil { 407 t.Fatal(err) 408 } 409 gotContent, err := ioutil.ReadAll(r) 410 if err != nil { 411 t.Fatal(err) 412 } 413 if !bytes.Equal(gotContent, tc.WantContent) { 414 t.Errorf("got content %q want %q", string(gotContent), string(tc.WantContent)) 415 } 416 r.Close() 417 // Verify we can read again. 418 r, err = lf.Reader() 419 if err != nil { 420 t.Fatal(err) 421 } 422 gotContent, err = ioutil.ReadAll(r) 423 if err != nil { 424 t.Fatal(err) 425 } 426 r.Close() 427 if !bytes.Equal(gotContent, tc.WantContent) { 428 t.Errorf("got content %q want %q", string(gotContent), string(tc.WantContent)) 429 } 430 }) 431 } 432 } 433 434 func TestOrdering(t *testing.T) { 435 tests := []struct { 436 Description string 437 Uploads []string 438 Ordering []*regexp.Regexp 439 Want [][]string 440 }{ 441 { 442 Description: "empty", 443 Want: [][]string{nil}, 444 }, 445 { 446 Description: "no ordering", 447 Uploads: []string{"c", "b", "a", "d"}, 448 Want: [][]string{{"a", "b", "c", "d"}}, 449 }, 450 { 451 Description: "one ordering", 452 Uploads: []string{"db", "c", "b", "a", "da"}, 453 Ordering: []*regexp.Regexp{regexp.MustCompile("^d")}, 454 Want: [][]string{{"da", "db"}, {"a", "b", "c"}}, 455 }, 456 { 457 Description: "two orderings", 458 Uploads: []string{"db", "c", "b", "a", "da"}, 459 Ordering: []*regexp.Regexp{ 460 regexp.MustCompile("^d"), 461 regexp.MustCompile("^b"), 462 }, 463 Want: [][]string{{"da", "db"}, {"b"}, {"a", "c"}}, 464 }, 465 } 466 467 for _, tc := range tests { 468 t.Run(tc.Description, func(t *testing.T) { 469 uploads := make([]*fileToUpload, len(tc.Uploads)) 470 for i, u := range tc.Uploads { 471 uploads[i] = &fileToUpload{Local: &localFile{SlashPath: u}} 472 } 473 gotUploads := applyOrdering(tc.Ordering, uploads) 474 var got [][]string 475 for _, subslice := range gotUploads { 476 var gotsubslice []string 477 for _, u := range subslice { 478 gotsubslice = append(gotsubslice, u.Local.SlashPath) 479 } 480 got = append(got, gotsubslice) 481 } 482 if diff := cmp.Diff(got, tc.Want); diff != "" { 483 t.Error(diff) 484 } 485 }) 486 } 487 } 488 489 type fileData struct { 490 Name string // name of the file 491 Contents string // contents of the file 492 } 493 494 // initLocalFs initializes fs with some test files. 495 func initLocalFs(ctx context.Context, fs afero.Fs) ([]*fileData, error) { 496 // The initial local filesystem. 497 local := []*fileData{ 498 {"aaa", "aaa"}, 499 {"bbb", "bbb"}, 500 {"subdir/aaa", "subdir-aaa"}, 501 {"subdir/nested/aaa", "subdir-nested-aaa"}, 502 {"subdir2/bbb", "subdir2-bbb"}, 503 } 504 if err := writeFiles(fs, local); err != nil { 505 return nil, err 506 } 507 return local, nil 508 } 509 510 // fsTest represents an (afero.FS, Go CDK blob.Bucket) against which end-to-end 511 // tests can be run. 512 type fsTest struct { 513 name string 514 fs afero.Fs 515 bucket *blob.Bucket 516 } 517 518 // initFsTests initializes a pair of tests for end-to-end test: 519 // 1. An in-memory afero.Fs paired with an in-memory Go CDK bucket. 520 // 2. A filesystem-based afero.Fs paired with an filesystem-based Go CDK bucket. 521 // It returns the pair of tests and a cleanup function. 522 func initFsTests() ([]*fsTest, func(), error) { 523 tmpfsdir, err := ioutil.TempDir("", "fs") 524 if err != nil { 525 return nil, nil, err 526 } 527 tmpbucketdir, err := ioutil.TempDir("", "bucket") 528 if err != nil { 529 return nil, nil, err 530 } 531 532 memfs := afero.NewMemMapFs() 533 membucket := memblob.OpenBucket(nil) 534 535 filefs := afero.NewBasePathFs(afero.NewOsFs(), tmpfsdir) 536 filebucket, err := fileblob.OpenBucket(tmpbucketdir, nil) 537 if err != nil { 538 return nil, nil, err 539 } 540 541 tests := []*fsTest{ 542 {"mem", memfs, membucket}, 543 {"file", filefs, filebucket}, 544 } 545 cleanup := func() { 546 membucket.Close() 547 filebucket.Close() 548 os.RemoveAll(tmpfsdir) 549 os.RemoveAll(tmpbucketdir) 550 } 551 return tests, cleanup, nil 552 } 553 554 // TestEndToEndSync verifies that basic adds, updates, and deletes are working 555 // correctly. 556 func TestEndToEndSync(t *testing.T) { 557 ctx := context.Background() 558 tests, cleanup, err := initFsTests() 559 if err != nil { 560 t.Fatal(err) 561 } 562 defer cleanup() 563 for _, test := range tests { 564 t.Run(test.name, func(t *testing.T) { 565 local, err := initLocalFs(ctx, test.fs) 566 if err != nil { 567 t.Fatal(err) 568 } 569 deployer := &Deployer{ 570 localFs: test.fs, 571 maxDeletes: -1, 572 bucket: test.bucket, 573 mediaTypes: media.DefaultTypes, 574 } 575 576 // Initial deployment should sync remote with local. 577 if err := deployer.Deploy(ctx); err != nil { 578 t.Errorf("initial deploy: failed: %v", err) 579 } 580 wantSummary := deploySummary{NumLocal: 5, NumRemote: 0, NumUploads: 5, NumDeletes: 0} 581 if !cmp.Equal(deployer.summary, wantSummary) { 582 t.Errorf("initial deploy: got %v, want %v", deployer.summary, wantSummary) 583 } 584 if diff, err := verifyRemote(ctx, deployer.bucket, local); err != nil { 585 t.Errorf("initial deploy: failed to verify remote: %v", err) 586 } else if diff != "" { 587 t.Errorf("initial deploy: remote snapshot doesn't match expected:\n%v", diff) 588 } 589 590 // A repeat deployment shouldn't change anything. 591 if err := deployer.Deploy(ctx); err != nil { 592 t.Errorf("no-op deploy: %v", err) 593 } 594 wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 0, NumDeletes: 0} 595 if !cmp.Equal(deployer.summary, wantSummary) { 596 t.Errorf("no-op deploy: got %v, want %v", deployer.summary, wantSummary) 597 } 598 599 // Make some changes to the local filesystem: 600 // 1. Modify file [0]. 601 // 2. Delete file [1]. 602 // 3. Add a new file (sorted last). 603 updatefd := local[0] 604 updatefd.Contents = "new contents" 605 deletefd := local[1] 606 local = append(local[:1], local[2:]...) // removing deleted [1] 607 newfd := &fileData{"zzz", "zzz"} 608 local = append(local, newfd) 609 if err := writeFiles(test.fs, []*fileData{updatefd, newfd}); err != nil { 610 t.Fatal(err) 611 } 612 if err := test.fs.Remove(deletefd.Name); err != nil { 613 t.Fatal(err) 614 } 615 616 // A deployment should apply those 3 changes. 617 if err := deployer.Deploy(ctx); err != nil { 618 t.Errorf("deploy after changes: failed: %v", err) 619 } 620 wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 2, NumDeletes: 1} 621 if !cmp.Equal(deployer.summary, wantSummary) { 622 t.Errorf("deploy after changes: got %v, want %v", deployer.summary, wantSummary) 623 } 624 if diff, err := verifyRemote(ctx, deployer.bucket, local); err != nil { 625 t.Errorf("deploy after changes: failed to verify remote: %v", err) 626 } else if diff != "" { 627 t.Errorf("deploy after changes: remote snapshot doesn't match expected:\n%v", diff) 628 } 629 630 // Again, a repeat deployment shouldn't change anything. 631 if err := deployer.Deploy(ctx); err != nil { 632 t.Errorf("no-op deploy: %v", err) 633 } 634 wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 0, NumDeletes: 0} 635 if !cmp.Equal(deployer.summary, wantSummary) { 636 t.Errorf("no-op deploy: got %v, want %v", deployer.summary, wantSummary) 637 } 638 }) 639 } 640 } 641 642 // TestMaxDeletes verifies that the "maxDeletes" flag is working correctly. 643 func TestMaxDeletes(t *testing.T) { 644 ctx := context.Background() 645 tests, cleanup, err := initFsTests() 646 if err != nil { 647 t.Fatal(err) 648 } 649 defer cleanup() 650 for _, test := range tests { 651 t.Run(test.name, func(t *testing.T) { 652 local, err := initLocalFs(ctx, test.fs) 653 if err != nil { 654 t.Fatal(err) 655 } 656 deployer := &Deployer{ 657 localFs: test.fs, 658 maxDeletes: -1, 659 bucket: test.bucket, 660 mediaTypes: media.DefaultTypes, 661 } 662 663 // Sync remote with local. 664 if err := deployer.Deploy(ctx); err != nil { 665 t.Errorf("initial deploy: failed: %v", err) 666 } 667 wantSummary := deploySummary{NumLocal: 5, NumRemote: 0, NumUploads: 5, NumDeletes: 0} 668 if !cmp.Equal(deployer.summary, wantSummary) { 669 t.Errorf("initial deploy: got %v, want %v", deployer.summary, wantSummary) 670 } 671 672 // Delete two files, [1] and [2]. 673 if err := test.fs.Remove(local[1].Name); err != nil { 674 t.Fatal(err) 675 } 676 if err := test.fs.Remove(local[2].Name); err != nil { 677 t.Fatal(err) 678 } 679 680 // A deployment with maxDeletes=0 shouldn't change anything. 681 deployer.maxDeletes = 0 682 if err := deployer.Deploy(ctx); err != nil { 683 t.Errorf("deploy failed: %v", err) 684 } 685 wantSummary = deploySummary{NumLocal: 3, NumRemote: 5, NumUploads: 0, NumDeletes: 0} 686 if !cmp.Equal(deployer.summary, wantSummary) { 687 t.Errorf("deploy: got %v, want %v", deployer.summary, wantSummary) 688 } 689 690 // A deployment with maxDeletes=1 shouldn't change anything either. 691 deployer.maxDeletes = 1 692 if err := deployer.Deploy(ctx); err != nil { 693 t.Errorf("deploy failed: %v", err) 694 } 695 wantSummary = deploySummary{NumLocal: 3, NumRemote: 5, NumUploads: 0, NumDeletes: 0} 696 if !cmp.Equal(deployer.summary, wantSummary) { 697 t.Errorf("deploy: got %v, want %v", deployer.summary, wantSummary) 698 } 699 700 // A deployment with maxDeletes=2 should make the changes. 701 deployer.maxDeletes = 2 702 if err := deployer.Deploy(ctx); err != nil { 703 t.Errorf("deploy failed: %v", err) 704 } 705 wantSummary = deploySummary{NumLocal: 3, NumRemote: 5, NumUploads: 0, NumDeletes: 2} 706 if !cmp.Equal(deployer.summary, wantSummary) { 707 t.Errorf("deploy: got %v, want %v", deployer.summary, wantSummary) 708 } 709 710 // Delete two more files, [0] and [3]. 711 if err := test.fs.Remove(local[0].Name); err != nil { 712 t.Fatal(err) 713 } 714 if err := test.fs.Remove(local[3].Name); err != nil { 715 t.Fatal(err) 716 } 717 718 // A deployment with maxDeletes=-1 should make the changes. 719 deployer.maxDeletes = -1 720 if err := deployer.Deploy(ctx); err != nil { 721 t.Errorf("deploy failed: %v", err) 722 } 723 wantSummary = deploySummary{NumLocal: 1, NumRemote: 3, NumUploads: 0, NumDeletes: 2} 724 if !cmp.Equal(deployer.summary, wantSummary) { 725 t.Errorf("deploy: got %v, want %v", deployer.summary, wantSummary) 726 } 727 }) 728 } 729 } 730 731 // TestIncludeExclude verifies that the include/exclude options for targets work. 732 func TestIncludeExclude(t *testing.T) { 733 ctx := context.Background() 734 tests := []struct { 735 Include string 736 Exclude string 737 Want deploySummary 738 }{ 739 { 740 Want: deploySummary{NumLocal: 5, NumUploads: 5}, 741 }, 742 { 743 Include: "**aaa", 744 Want: deploySummary{NumLocal: 3, NumUploads: 3}, 745 }, 746 { 747 Include: "**bbb", 748 Want: deploySummary{NumLocal: 2, NumUploads: 2}, 749 }, 750 { 751 Include: "aaa", 752 Want: deploySummary{NumLocal: 1, NumUploads: 1}, 753 }, 754 { 755 Exclude: "**aaa", 756 Want: deploySummary{NumLocal: 2, NumUploads: 2}, 757 }, 758 { 759 Exclude: "**bbb", 760 Want: deploySummary{NumLocal: 3, NumUploads: 3}, 761 }, 762 { 763 Exclude: "aaa", 764 Want: deploySummary{NumLocal: 4, NumUploads: 4}, 765 }, 766 { 767 Include: "**aaa", 768 Exclude: "**nested**", 769 Want: deploySummary{NumLocal: 2, NumUploads: 2}, 770 }, 771 } 772 for _, test := range tests { 773 t.Run(fmt.Sprintf("include %q exclude %q", test.Include, test.Exclude), func(t *testing.T) { 774 fsTests, cleanup, err := initFsTests() 775 if err != nil { 776 t.Fatal(err) 777 } 778 defer cleanup() 779 fsTest := fsTests[1] // just do file-based test 780 781 _, err = initLocalFs(ctx, fsTest.fs) 782 if err != nil { 783 t.Fatal(err) 784 } 785 tgt := &target{ 786 Include: test.Include, 787 Exclude: test.Exclude, 788 } 789 if err := tgt.parseIncludeExclude(); err != nil { 790 t.Error(err) 791 } 792 deployer := &Deployer{ 793 localFs: fsTest.fs, 794 maxDeletes: -1, 795 bucket: fsTest.bucket, 796 target: tgt, 797 mediaTypes: media.DefaultTypes, 798 } 799 800 // Sync remote with local. 801 if err := deployer.Deploy(ctx); err != nil { 802 t.Errorf("deploy: failed: %v", err) 803 } 804 if !cmp.Equal(deployer.summary, test.Want) { 805 t.Errorf("deploy: got %v, want %v", deployer.summary, test.Want) 806 } 807 }) 808 } 809 } 810 811 // TestIncludeExcludeRemoteDelete verifies deleted local files that don't match include/exclude patterns 812 // are not deleted on the remote. 813 func TestIncludeExcludeRemoteDelete(t *testing.T) { 814 ctx := context.Background() 815 816 tests := []struct { 817 Include string 818 Exclude string 819 Want deploySummary 820 }{ 821 { 822 Want: deploySummary{NumLocal: 3, NumRemote: 5, NumUploads: 0, NumDeletes: 2}, 823 }, 824 { 825 Include: "**aaa", 826 Want: deploySummary{NumLocal: 2, NumRemote: 3, NumUploads: 0, NumDeletes: 1}, 827 }, 828 { 829 Include: "subdir/**", 830 Want: deploySummary{NumLocal: 1, NumRemote: 2, NumUploads: 0, NumDeletes: 1}, 831 }, 832 { 833 Exclude: "**bbb", 834 Want: deploySummary{NumLocal: 2, NumRemote: 3, NumUploads: 0, NumDeletes: 1}, 835 }, 836 { 837 Exclude: "bbb", 838 Want: deploySummary{NumLocal: 3, NumRemote: 4, NumUploads: 0, NumDeletes: 1}, 839 }, 840 } 841 for _, test := range tests { 842 t.Run(fmt.Sprintf("include %q exclude %q", test.Include, test.Exclude), func(t *testing.T) { 843 fsTests, cleanup, err := initFsTests() 844 if err != nil { 845 t.Fatal(err) 846 } 847 defer cleanup() 848 fsTest := fsTests[1] // just do file-based test 849 850 local, err := initLocalFs(ctx, fsTest.fs) 851 if err != nil { 852 t.Fatal(err) 853 } 854 deployer := &Deployer{ 855 localFs: fsTest.fs, 856 maxDeletes: -1, 857 bucket: fsTest.bucket, 858 mediaTypes: media.DefaultTypes, 859 } 860 861 // Initial sync to get the files on the remote 862 if err := deployer.Deploy(ctx); err != nil { 863 t.Errorf("deploy: failed: %v", err) 864 } 865 866 // Delete two files, [1] and [2]. 867 if err := fsTest.fs.Remove(local[1].Name); err != nil { 868 t.Fatal(err) 869 } 870 if err := fsTest.fs.Remove(local[2].Name); err != nil { 871 t.Fatal(err) 872 } 873 874 // Second sync 875 tgt := &target{ 876 Include: test.Include, 877 Exclude: test.Exclude, 878 } 879 if err := tgt.parseIncludeExclude(); err != nil { 880 t.Error(err) 881 } 882 deployer.target = tgt 883 if err := deployer.Deploy(ctx); err != nil { 884 t.Errorf("deploy: failed: %v", err) 885 } 886 887 if !cmp.Equal(deployer.summary, test.Want) { 888 t.Errorf("deploy: got %v, want %v", deployer.summary, test.Want) 889 } 890 }) 891 } 892 } 893 894 // TestCompression verifies that gzip compression works correctly. 895 // In particular, MD5 hashes must be of the compressed content. 896 func TestCompression(t *testing.T) { 897 ctx := context.Background() 898 899 tests, cleanup, err := initFsTests() 900 if err != nil { 901 t.Fatal(err) 902 } 903 defer cleanup() 904 for _, test := range tests { 905 t.Run(test.name, func(t *testing.T) { 906 local, err := initLocalFs(ctx, test.fs) 907 if err != nil { 908 t.Fatal(err) 909 } 910 deployer := &Deployer{ 911 localFs: test.fs, 912 bucket: test.bucket, 913 matchers: []*matcher{{Pattern: ".*", Gzip: true, re: regexp.MustCompile(".*")}}, 914 mediaTypes: media.DefaultTypes, 915 } 916 917 // Initial deployment should sync remote with local. 918 if err := deployer.Deploy(ctx); err != nil { 919 t.Errorf("initial deploy: failed: %v", err) 920 } 921 wantSummary := deploySummary{NumLocal: 5, NumRemote: 0, NumUploads: 5, NumDeletes: 0} 922 if !cmp.Equal(deployer.summary, wantSummary) { 923 t.Errorf("initial deploy: got %v, want %v", deployer.summary, wantSummary) 924 } 925 926 // A repeat deployment shouldn't change anything. 927 if err := deployer.Deploy(ctx); err != nil { 928 t.Errorf("no-op deploy: %v", err) 929 } 930 wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 0, NumDeletes: 0} 931 if !cmp.Equal(deployer.summary, wantSummary) { 932 t.Errorf("no-op deploy: got %v, want %v", deployer.summary, wantSummary) 933 } 934 935 // Make an update to the local filesystem, on [1]. 936 updatefd := local[1] 937 updatefd.Contents = "new contents" 938 if err := writeFiles(test.fs, []*fileData{updatefd}); err != nil { 939 t.Fatal(err) 940 } 941 942 // A deployment should apply the changes. 943 if err := deployer.Deploy(ctx); err != nil { 944 t.Errorf("deploy after changes: failed: %v", err) 945 } 946 wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 1, NumDeletes: 0} 947 if !cmp.Equal(deployer.summary, wantSummary) { 948 t.Errorf("deploy after changes: got %v, want %v", deployer.summary, wantSummary) 949 } 950 }) 951 } 952 } 953 954 // TestMatching verifies that matchers match correctly, and that the Force 955 // attribute for matcher works. 956 func TestMatching(t *testing.T) { 957 ctx := context.Background() 958 tests, cleanup, err := initFsTests() 959 if err != nil { 960 t.Fatal(err) 961 } 962 defer cleanup() 963 for _, test := range tests { 964 t.Run(test.name, func(t *testing.T) { 965 _, err := initLocalFs(ctx, test.fs) 966 if err != nil { 967 t.Fatal(err) 968 } 969 deployer := &Deployer{ 970 localFs: test.fs, 971 bucket: test.bucket, 972 matchers: []*matcher{{Pattern: "^subdir/aaa$", Force: true, re: regexp.MustCompile("^subdir/aaa$")}}, 973 mediaTypes: media.DefaultTypes, 974 } 975 976 // Initial deployment to sync remote with local. 977 if err := deployer.Deploy(ctx); err != nil { 978 t.Errorf("initial deploy: failed: %v", err) 979 } 980 wantSummary := deploySummary{NumLocal: 5, NumRemote: 0, NumUploads: 5, NumDeletes: 0} 981 if !cmp.Equal(deployer.summary, wantSummary) { 982 t.Errorf("initial deploy: got %v, want %v", deployer.summary, wantSummary) 983 } 984 985 // A repeat deployment should upload a single file, the one that matched the Force matcher. 986 // Note that matching happens based on the ToSlash form, so this matches 987 // even on Windows. 988 if err := deployer.Deploy(ctx); err != nil { 989 t.Errorf("no-op deploy with single force matcher: %v", err) 990 } 991 wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 1, NumDeletes: 0} 992 if !cmp.Equal(deployer.summary, wantSummary) { 993 t.Errorf("no-op deploy with single force matcher: got %v, want %v", deployer.summary, wantSummary) 994 } 995 996 // Repeat with a matcher that should now match 3 files. 997 deployer.matchers = []*matcher{{Pattern: "aaa", Force: true, re: regexp.MustCompile("aaa")}} 998 if err := deployer.Deploy(ctx); err != nil { 999 t.Errorf("no-op deploy with triple force matcher: %v", err) 1000 } 1001 wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 3, NumDeletes: 0} 1002 if !cmp.Equal(deployer.summary, wantSummary) { 1003 t.Errorf("no-op deploy with triple force matcher: got %v, want %v", deployer.summary, wantSummary) 1004 } 1005 }) 1006 } 1007 } 1008 1009 // writeFiles writes the files in fds to fd. 1010 func writeFiles(fs afero.Fs, fds []*fileData) error { 1011 for _, fd := range fds { 1012 dir := path.Dir(fd.Name) 1013 if dir != "." { 1014 err := fs.MkdirAll(dir, os.ModePerm) 1015 if err != nil { 1016 return err 1017 } 1018 } 1019 f, err := fs.Create(fd.Name) 1020 if err != nil { 1021 return err 1022 } 1023 defer f.Close() 1024 _, err = f.WriteString(fd.Contents) 1025 if err != nil { 1026 return err 1027 } 1028 } 1029 return nil 1030 } 1031 1032 // verifyRemote that the current contents of bucket matches local. 1033 // It returns an empty string if the contents matched, and a non-empty string 1034 // capturing the diff if they didn't. 1035 func verifyRemote(ctx context.Context, bucket *blob.Bucket, local []*fileData) (string, error) { 1036 var cur []*fileData 1037 iter := bucket.List(nil) 1038 for { 1039 obj, err := iter.Next(ctx) 1040 if err == io.EOF { 1041 break 1042 } 1043 if err != nil { 1044 return "", err 1045 } 1046 contents, err := bucket.ReadAll(ctx, obj.Key) 1047 if err != nil { 1048 return "", err 1049 } 1050 cur = append(cur, &fileData{obj.Key, string(contents)}) 1051 } 1052 if cmp.Equal(cur, local) { 1053 return "", nil 1054 } 1055 diff := "got: \n" 1056 for _, f := range cur { 1057 diff += fmt.Sprintf(" %s: %s\n", f.Name, f.Contents) 1058 } 1059 diff += "want: \n" 1060 for _, f := range local { 1061 diff += fmt.Sprintf(" %s: %s\n", f.Name, f.Contents) 1062 } 1063 return diff, nil 1064 }