github.com/GoogleCloudPlatform/testgrid@v0.0.174/pkg/merger/merger_test.go (about) 1 /* 2 Copyright 2021 The Kubernetes Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package merger 18 19 import ( 20 "bytes" 21 "context" 22 "errors" 23 "fmt" 24 "io" 25 "io/ioutil" 26 "testing" 27 "time" 28 29 "cloud.google.com/go/storage" 30 "github.com/golang/protobuf/proto" 31 "github.com/google/go-cmp/cmp" 32 33 configpb "github.com/GoogleCloudPlatform/testgrid/pb/config" 34 "github.com/GoogleCloudPlatform/testgrid/util/gcs" 35 ) 36 37 func newPathOrDie(s string) *gcs.Path { 38 p, err := gcs.NewPath(s) 39 if err != nil { 40 panic(err) 41 } 42 return p 43 } 44 45 func Test_ParseAndCheck(t *testing.T) { 46 tc := []struct { 47 name string 48 input []byte 49 expectedList MergeList 50 expectError bool 51 }{ 52 { 53 name: "Empty MergeList will return an error", 54 expectError: true, 55 }, 56 { 57 name: "Parses YAML examples", 58 input: []byte(`target: "gs://path/to/write/config" 59 sources: 60 - name: "red" 61 location: "gs://example/red-team/config" 62 contact: "red-admin@example.com" 63 - name: "blue" 64 location: "gs://example/blue-team/config" 65 contact: "blue.team.contact@example.com"`), 66 expectedList: MergeList{ 67 Target: "gs://path/to/write/config", 68 Path: newPathOrDie("gs://path/to/write/config"), 69 Sources: []Source{ 70 { 71 Name: "red", 72 Location: "gs://example/red-team/config", 73 Path: newPathOrDie("gs://example/red-team/config"), 74 Contact: "red-admin@example.com", 75 }, 76 { 77 Name: "blue", 78 Location: "gs://example/blue-team/config", 79 Path: newPathOrDie("gs://example/blue-team/config"), 80 Contact: "blue.team.contact@example.com", 81 }, 82 }, 83 }, 84 }, 85 { 86 name: "Tolerate missing contacts", 87 input: []byte(`target: "gs://path/to/write/config" 88 sources: 89 - name: "red" 90 location: "gs://example/red-team/config" 91 - name: "blue" 92 location: "gs://example/blue-team/config"`), 93 expectedList: MergeList{ 94 Target: "gs://path/to/write/config", 95 Path: newPathOrDie("gs://path/to/write/config"), 96 Sources: []Source{ 97 { 98 Name: "red", 99 Location: "gs://example/red-team/config", 100 Path: newPathOrDie("gs://example/red-team/config"), 101 }, 102 { 103 Name: "blue", 104 Location: "gs://example/blue-team/config", 105 Path: newPathOrDie("gs://example/blue-team/config"), 106 }, 107 }, 108 }, 109 }, 110 { 111 name: "Target is local filesystem path", 112 input: []byte(`target: "/tmp/config" 113 sources: 114 - name: "red" 115 location: "gs://example/red-team/config" 116 contact: "red-admin@example.com" 117 - name: "blue" 118 location: "gs://example/blue-team/config" 119 contact: "blue.team.contact@example.com"`), 120 expectedList: MergeList{ 121 Target: "/tmp/config", 122 Path: newPathOrDie("/tmp/config"), 123 Sources: []Source{ 124 { 125 Name: "red", 126 Location: "gs://example/red-team/config", 127 Path: newPathOrDie("gs://example/red-team/config"), 128 Contact: "red-admin@example.com", 129 }, 130 { 131 Name: "blue", 132 Location: "gs://example/blue-team/config", 133 Path: newPathOrDie("gs://example/blue-team/config"), 134 Contact: "blue.team.contact@example.com", 135 }, 136 }, 137 }, 138 }, 139 { 140 name: "Target is file:// path", 141 input: []byte(`target: "file://tmp/config" 142 sources: 143 - name: "red" 144 location: "gs://example/red-team/config" 145 contact: "red-admin@example.com" 146 - name: "blue" 147 location: "gs://example/blue-team/config" 148 contact: "blue.team.contact@example.com"`), 149 expectedList: MergeList{ 150 Target: "file://tmp/config", 151 Path: newPathOrDie("file://tmp/config"), 152 Sources: []Source{ 153 { 154 Name: "red", 155 Location: "gs://example/red-team/config", 156 Path: newPathOrDie("gs://example/red-team/config"), 157 Contact: "red-admin@example.com", 158 }, 159 { 160 Name: "blue", 161 Location: "gs://example/blue-team/config", 162 Path: newPathOrDie("gs://example/blue-team/config"), 163 Contact: "blue.team.contact@example.com", 164 }, 165 }, 166 }, 167 }, 168 { 169 name: "Target is invalid path", 170 input: []byte(`target: "foo://config" 171 sources: 172 - name: "red" 173 location: "gs://example/red-team/config" 174 contact: "red-admin@example.com" 175 - name: "blue" 176 location: "gs://example/blue-team/config" 177 contact: "blue.team.contact@example.com"`), 178 expectError: true, 179 }, 180 { 181 name: "Source contains a local filesystem path", 182 input: []byte(`target: "gs://path/to/write/config" 183 sources: 184 - name: "red" 185 location: "/tmp/config" 186 contact: "red-admin@example.com" 187 - name: "blue" 188 location: "file://example/blue-team/config" 189 contact: "blue.team.contact@example.com"`), 190 expectedList: MergeList{ 191 Target: "gs://path/to/write/config", 192 Path: newPathOrDie("gs://path/to/write/config"), 193 Sources: []Source{ 194 { 195 Name: "red", 196 Location: "/tmp/config", 197 Path: newPathOrDie("/tmp/config"), 198 Contact: "red-admin@example.com", 199 }, 200 { 201 Name: "blue", 202 Location: "file://example/blue-team/config", 203 Path: newPathOrDie("file://example/blue-team/config"), 204 Contact: "blue.team.contact@example.com", 205 }, 206 }, 207 }, 208 }, 209 { 210 name: "Source contains an invalid path, returns error", 211 input: []byte(`target: "gs://path/to/write/config" 212 sources: 213 - name: "red" 214 location: "foo://config" 215 contact: "red-admin@example.com" 216 - name: "blue" 217 location: "gs://example/blue-team/config" 218 contact: "blue.team.contact@example.com"`), 219 expectError: true, 220 }, 221 { 222 name: "Contains a duplicated name, returns error", 223 input: []byte(`target: "gs://path/to/write/config" 224 sources: 225 - name: "red" 226 location: "gs://example/red-team/config" 227 - name: "red" 228 location: "gs://example/new-red-team/config"`), 229 expectError: true, 230 }, 231 } 232 233 for _, test := range tc { 234 t.Run(test.name, func(t *testing.T) { 235 resultList, err := ParseAndCheck(test.input) 236 if test.expectError { 237 if err == nil { 238 t.Fatal("Expected error, but got none") 239 } 240 return 241 } 242 if err != nil { 243 t.Errorf("Unexpected error %v", err) 244 } 245 if diff := cmp.Diff(test.expectedList, resultList, cmp.AllowUnexported(gcs.Path{})); diff != "" { 246 t.Errorf("ParseAndCheck(%q) differed (-got, +want): %s", test.input, diff) 247 } 248 }) 249 } 250 } 251 252 func Test_MergeAndUpdate(t *testing.T) { 253 cases := []struct { 254 name string 255 paths map[string]*gcs.Path 256 uploadInjectedError error 257 skipValidate bool 258 confirm bool 259 expectError bool 260 expectUpload bool 261 }{ 262 { 263 name: "No paths to read from; fails", 264 confirm: true, 265 expectError: true, 266 }, 267 { 268 name: "Intended upload; succeeds", 269 paths: map[string]*gcs.Path{ 270 "first": newPathOrDie("gs://valid/config"), 271 }, 272 confirm: true, 273 expectUpload: true, 274 }, 275 { 276 name: "Given nil path; fails", 277 paths: map[string]*gcs.Path{ 278 "first": newPathOrDie("gs://valid/config"), 279 "second": nil, 280 }, 281 confirm: true, 282 expectError: true, 283 }, 284 { 285 name: "Open fails; fails", 286 paths: map[string]*gcs.Path{ 287 "first": newPathOrDie("gs://valid/config"), 288 "second": newPathOrDie("gs://read/error"), 289 }, 290 confirm: true, 291 expectError: true, 292 }, 293 { 294 name: "Validate fails; skips and succeeds", 295 paths: map[string]*gcs.Path{ 296 "first": newPathOrDie("gs://valid/config"), 297 "second": newPathOrDie("gs://invalid/config"), 298 }, 299 confirm: true, 300 expectUpload: true, 301 }, 302 { 303 name: "Validate fails for all targets; fails", 304 paths: map[string]*gcs.Path{ 305 "first": newPathOrDie("gs://invalid/config"), 306 }, 307 confirm: true, 308 expectError: true, 309 }, 310 { 311 name: "Upload fails; fails", 312 paths: map[string]*gcs.Path{ 313 "first": newPathOrDie("gs://valid/config"), 314 }, 315 uploadInjectedError: errors.New("upload error"), 316 confirm: true, 317 expectError: true, 318 }, 319 { 320 name: "no-confirm; succeeds with no upload", 321 paths: map[string]*gcs.Path{ 322 "first": newPathOrDie("gs://valid/config"), 323 }, 324 }, 325 { 326 name: "skip-validate with invalid proto; succeeds", 327 paths: map[string]*gcs.Path{ 328 "second": newPathOrDie("gs://invalid/config"), 329 }, 330 skipValidate: true, 331 confirm: true, 332 expectUpload: true, 333 }, 334 } 335 336 for _, tc := range cases { 337 existingData := fakeOpener{ 338 "gs://valid/config": configInFake(&configpb.Configuration{ 339 Dashboards: []*configpb.Dashboard{ 340 { 341 Name: "dash_1", 342 DashboardTab: []*configpb.DashboardTab{ 343 { 344 Name: "tab_1", 345 TestGroupName: "test_group_1", 346 }, 347 }, 348 }, 349 }, 350 TestGroups: []*configpb.TestGroup{ 351 { 352 Name: "test_group_1", 353 GcsPrefix: "tests_live_here", 354 DaysOfResults: 1, 355 NumColumnsRecent: 1, 356 }, 357 }, 358 }), 359 "gs://invalid/config": configInFake(&configpb.Configuration{ 360 Dashboards: []*configpb.Dashboard{ 361 {Name: "dash_1"}, 362 {Name: "dash_1"}, 363 }, 364 }), 365 "gs://read/error": fakeObject{ 366 err: errors.New("read error"), 367 }, 368 } 369 370 t.Run(tc.name, func(t *testing.T) { 371 client := fakeMergeClient{ 372 fakeUploader: fakeUploader{ 373 err: tc.uploadInjectedError, 374 }, 375 fakeOpener: existingData, 376 } 377 378 mergeList := MergeList{ 379 Target: "gs://result/config", 380 Path: newPathOrDie("gs://result/config"), 381 Sources: nil, 382 } 383 384 for name, path := range tc.paths { 385 mergeList.Sources = append(mergeList.Sources, Source{ 386 Name: name, 387 Path: path, 388 }) 389 } 390 391 _, resultErr := MergeAndUpdate(context.Background(), &client, nil, mergeList, tc.skipValidate, tc.confirm) 392 393 if tc.expectUpload && !client.uploaded { 394 t.Errorf("Expected upload, but there was none") 395 } 396 397 if !tc.expectUpload && client.uploaded { 398 t.Errorf("Unexpected upload") 399 } 400 401 if tc.expectError && resultErr == nil { 402 t.Errorf("Expected error, but got none") 403 } 404 405 if !tc.expectError && resultErr != nil { 406 t.Errorf("Unexpected error %v", resultErr) 407 } 408 }) 409 } 410 } 411 func Test_RecordLastModified(t *testing.T) { 412 cases := []struct { 413 name string 414 attrs *storage.ReaderObjectAttrs 415 mets *Metrics 416 source string 417 expectAtLeast int64 418 }{ 419 { 420 name: "nil attrs; succeeds", 421 attrs: nil, 422 mets: &Metrics{}, 423 source: "fake-source", 424 }, 425 { 426 name: "nil mets; succeeds", 427 attrs: &storage.ReaderObjectAttrs{}, 428 mets: nil, 429 source: "fake-source", 430 }, 431 { 432 name: "nil; succeeds", 433 attrs: nil, 434 mets: nil, 435 source: "", 436 }, { 437 name: "non-nil mets and attrs; succeeds", 438 attrs: &storage.ReaderObjectAttrs{ 439 LastModified: time.Now().Add(-5 * time.Second), 440 }, 441 mets: &Metrics{}, 442 source: "", 443 expectAtLeast: 5, 444 }, 445 } 446 447 for _, tc := range cases { 448 t.Run(tc.name, func(t *testing.T) { 449 if tc.attrs == nil || tc.mets == nil { 450 recordLastModified(tc.attrs, tc.mets, tc.source) 451 } else { 452 var fakeMetric fakeInt64 453 tc.mets.LastModified = &fakeMetric 454 recordLastModified(tc.attrs, tc.mets, tc.source) 455 if fakeMetric.last < tc.expectAtLeast { 456 t.Errorf("want at least %d, got %d", tc.expectAtLeast, fakeMetric.last) 457 } 458 } 459 }) 460 } 461 } 462 463 type fakeInt64 struct { 464 set bool 465 last int64 466 } 467 468 func (f *fakeInt64) Name() string { 469 return "fakeInt64" 470 } 471 472 func (f *fakeInt64) Set(n int64, _ ...string) { 473 f.last = n 474 f.set = true 475 } 476 477 type fakeMergeClient struct { 478 fakeOpener 479 fakeUploader 480 } 481 482 type fakeOpener map[string]fakeObject 483 484 func (fo fakeOpener) Open(_ context.Context, path gcs.Path) (io.ReadCloser, *storage.ReaderObjectAttrs, error) { 485 o, ok := fo[path.String()] 486 if !ok { 487 return nil, nil, fmt.Errorf("wrap not exist: %w", storage.ErrObjectNotExist) 488 } 489 if o.err != nil { 490 return nil, nil, fmt.Errorf("injected open error: %w", o.err) 491 } 492 return ioutil.NopCloser(bytes.NewReader(o.buf)), &storage.ReaderObjectAttrs{}, nil 493 } 494 495 type fakeObject struct { 496 buf []byte 497 err error 498 } 499 500 func configInFake(cfg *configpb.Configuration) (fo fakeObject) { 501 b, err := proto.Marshal(cfg) 502 if err != nil { 503 panic(err) 504 } 505 fo.buf = b 506 return 507 } 508 509 type fakeUploader struct { 510 uploaded bool 511 err error 512 } 513 514 func (fu *fakeUploader) Upload(context.Context, gcs.Path, []byte, bool, string) (*storage.ObjectAttrs, error) { 515 if fu.err != nil { 516 return nil, fmt.Errorf("injected upload error: %w", fu.err) 517 } 518 fu.uploaded = true 519 return nil, nil 520 }