go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config/impl/remote/remote_v2_test.go (about) 1 // Copyright 2023 The LUCI Authors. 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 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package remote 16 17 import ( 18 "bytes" 19 "context" 20 "net/http" 21 "net/http/httptest" 22 "net/url" 23 "strings" 24 "testing" 25 26 "github.com/golang/mock/gomock" 27 "github.com/klauspost/compress/gzip" 28 "google.golang.org/genproto/protobuf/field_mask" 29 "google.golang.org/grpc" 30 "google.golang.org/grpc/codes" 31 grpcGzip "google.golang.org/grpc/encoding/gzip" 32 "google.golang.org/grpc/status" 33 34 "go.chromium.org/luci/common/proto" 35 "go.chromium.org/luci/common/retry/transient" 36 pb "go.chromium.org/luci/config_service/proto" 37 38 "go.chromium.org/luci/config" 39 40 . "github.com/smartystreets/goconvey/convey" 41 . "go.chromium.org/luci/common/testing/assertions" 42 ) 43 44 func TestRemoteV2Calls(t *testing.T) { 45 t.Parallel() 46 47 Convey("Remote V2 calls", t, func() { 48 ctl := gomock.NewController(t) 49 mockClient := pb.NewMockConfigsClient(ctl) 50 v2Impl := remoteV2Impl{ 51 grpcClient: mockClient, 52 httpClient: http.DefaultClient, 53 } 54 ctx := context.Background() 55 56 Convey("GetConfig", func() { 57 Convey("ok - raw content", func() { 58 mockClient.EXPECT().GetConfig(gomock.Any(), proto.MatcherEqual(&pb.GetConfigRequest{ 59 ConfigSet: "projects/project1", 60 Path: "config.cfg", 61 }), grpc.UseCompressor(grpcGzip.Name)).Return(&pb.Config{ 62 ConfigSet: "projects/project1", 63 Path: "config.cfg", 64 Content: &pb.Config_RawContent{ 65 RawContent: []byte("content"), 66 }, 67 Revision: "revision", 68 ContentSha256: "sha256", 69 Url: "url", 70 }, nil) 71 72 cfg, err := v2Impl.GetConfig(ctx, config.Set("projects/project1"), "config.cfg", false) 73 74 So(err, ShouldBeNil) 75 So(cfg, ShouldResemble, &config.Config{ 76 Meta: config.Meta{ 77 ConfigSet: "projects/project1", 78 Path: "config.cfg", 79 ContentHash: "sha256", 80 Revision: "revision", 81 ViewURL: "url", 82 }, 83 Content: "content", 84 }) 85 }) 86 87 Convey("ok - signed url", func(c C) { 88 signedURLServer := signedURLServer(c, "content") 89 defer signedURLServer.Close() 90 91 mockClient.EXPECT().GetConfig(gomock.Any(), proto.MatcherEqual(&pb.GetConfigRequest{ 92 ConfigSet: "projects/project1", 93 Path: "config.cfg", 94 }), grpc.UseCompressor(grpcGzip.Name)).Return(&pb.Config{ 95 ConfigSet: "projects/project1", 96 Path: "config.cfg", 97 Content: &pb.Config_SignedUrl{ 98 SignedUrl: signedURLServer.URL, 99 }, 100 Revision: "revision", 101 ContentSha256: "sha256", 102 Url: "url", 103 }, nil) 104 105 cfg, err := v2Impl.GetConfig(ctx, config.Set("projects/project1"), "config.cfg", false) 106 107 So(err, ShouldBeNil) 108 So(cfg, ShouldResemble, &config.Config{ 109 Meta: config.Meta{ 110 ConfigSet: "projects/project1", 111 Path: "config.cfg", 112 ContentHash: "sha256", 113 Revision: "revision", 114 ViewURL: "url", 115 }, 116 Content: "content", 117 }) 118 }) 119 120 Convey("ok - meta only", func() { 121 mockClient.EXPECT().GetConfig(gomock.Any(), proto.MatcherEqual(&pb.GetConfigRequest{ 122 ConfigSet: "projects/project1", 123 Path: "config.cfg", 124 Fields: &field_mask.FieldMask{ 125 Paths: []string{"config_set", "path", "content_sha256", "revision", "url"}, 126 }, 127 }), grpc.UseCompressor(grpcGzip.Name)).Return(&pb.Config{ 128 ConfigSet: "projects/project1", 129 Path: "config.cfg", 130 Revision: "revision", 131 ContentSha256: "sha256", 132 Url: "url", 133 }, nil) 134 135 cfg, err := v2Impl.GetConfig(ctx, config.Set("projects/project1"), "config.cfg", true) 136 137 So(err, ShouldBeNil) 138 So(cfg, ShouldResemble, &config.Config{ 139 Meta: config.Meta{ 140 ConfigSet: "projects/project1", 141 Path: "config.cfg", 142 ContentHash: "sha256", 143 Revision: "revision", 144 ViewURL: "url", 145 }, 146 }) 147 }) 148 149 Convey("error - not found", func() { 150 mockClient.EXPECT().GetConfig(gomock.Any(), gomock.Any(), grpc.UseCompressor(grpcGzip.Name)).Return(nil, status.Errorf(codes.NotFound, "not found")) 151 152 cfg, err := v2Impl.GetConfig(ctx, config.Set("projects/project1"), "config.cfg", true) 153 154 So(cfg, ShouldBeNil) 155 So(err, ShouldErrLike, config.ErrNoConfig) 156 }) 157 158 Convey("error - other", func() { 159 mockClient.EXPECT().GetConfig(gomock.Any(), gomock.Any(), grpc.UseCompressor(grpcGzip.Name)).Return(nil, status.Errorf(codes.Internal, "internal error")) 160 161 cfg, err := v2Impl.GetConfig(ctx, config.Set("projects/project1"), "config.cfg", true) 162 163 So(cfg, ShouldBeNil) 164 So(err, ShouldHaveGRPCStatus, codes.Internal, "internal error") 165 So(transient.Tag.In(err), ShouldBeTrue) 166 }) 167 }) 168 169 Convey("GetProjectConfigs", func() { 170 171 Convey("ok - meta only", func() { 172 mockClient.EXPECT().GetProjectConfigs(gomock.Any(), proto.MatcherEqual(&pb.GetProjectConfigsRequest{ 173 Path: "config.cfg", 174 Fields: &field_mask.FieldMask{ 175 Paths: []string{"config_set", "path", "content_sha256", "revision", "url"}, 176 }, 177 }), grpc.UseCompressor(grpcGzip.Name)).Return(&pb.GetProjectConfigsResponse{ 178 Configs: []*pb.Config{ 179 { 180 ConfigSet: "projects/project1", 181 Path: "config.cfg", 182 Revision: "revision", 183 ContentSha256: "sha256", 184 Url: "url", 185 }, 186 }, 187 }, nil) 188 189 configs, err := v2Impl.GetProjectConfigs(ctx, "config.cfg", true) 190 So(err, ShouldBeNil) 191 So(configs, ShouldResemble, []config.Config{ 192 { 193 Meta: config.Meta{ 194 ConfigSet: "projects/project1", 195 Path: "config.cfg", 196 ContentHash: "sha256", 197 Revision: "revision", 198 ViewURL: "url", 199 }, 200 }, 201 }) 202 }) 203 204 Convey("ok - raw + signed url", func(c C) { 205 signedURLServer := signedURLServer(c, "large content") 206 defer signedURLServer.Close() 207 208 mockClient.EXPECT().GetProjectConfigs(gomock.Any(), proto.MatcherEqual(&pb.GetProjectConfigsRequest{ 209 Path: "config.cfg", 210 }), grpc.UseCompressor(grpcGzip.Name)).Return(&pb.GetProjectConfigsResponse{ 211 Configs: []*pb.Config{ 212 { 213 ConfigSet: "projects/project1", 214 Path: "config.cfg", 215 Revision: "revision", 216 ContentSha256: "sha256", 217 Url: "url", 218 Content: &pb.Config_RawContent{ 219 RawContent: []byte("small content"), 220 }, 221 }, 222 { 223 ConfigSet: "projects/project2", 224 Path: "config.cfg", 225 Revision: "revision", 226 ContentSha256: "sha256", 227 Url: "url", 228 Content: &pb.Config_SignedUrl{ 229 SignedUrl: signedURLServer.URL, 230 }, 231 }, 232 }, 233 }, nil) 234 235 configs, err := v2Impl.GetProjectConfigs(ctx, "config.cfg", false) 236 So(err, ShouldBeNil) 237 So(configs, ShouldResemble, []config.Config{ 238 { 239 Meta: config.Meta{ 240 ConfigSet: "projects/project1", 241 Path: "config.cfg", 242 ContentHash: "sha256", 243 Revision: "revision", 244 ViewURL: "url", 245 }, 246 Content: "small content", 247 }, 248 { 249 Meta: config.Meta{ 250 ConfigSet: "projects/project2", 251 Path: "config.cfg", 252 ContentHash: "sha256", 253 Revision: "revision", 254 ViewURL: "url", 255 }, 256 Content: "large content", 257 }, 258 }) 259 }) 260 261 Convey("empty response", func() { 262 mockClient.EXPECT().GetProjectConfigs(gomock.Any(), proto.MatcherEqual(&pb.GetProjectConfigsRequest{ 263 Path: "config.cfg", 264 }), grpc.UseCompressor(grpcGzip.Name)).Return(&pb.GetProjectConfigsResponse{}, nil) 265 266 configs, err := v2Impl.GetProjectConfigs(ctx, "config.cfg", false) 267 So(err, ShouldBeNil) 268 So(configs, ShouldBeEmpty) 269 }) 270 271 Convey("rpc error", func() { 272 mockClient.EXPECT().GetProjectConfigs(gomock.Any(), proto.MatcherEqual(&pb.GetProjectConfigsRequest{ 273 Path: "config.cfg", 274 }), grpc.UseCompressor(grpcGzip.Name)).Return(nil, status.Errorf(codes.Internal, "config server internal error")) 275 276 configs, err := v2Impl.GetProjectConfigs(ctx, "config.cfg", false) 277 So(configs, ShouldBeNil) 278 So(err, ShouldHaveGRPCStatus, codes.Internal, "config server internal error") 279 So(transient.Tag.In(err), ShouldBeTrue) 280 }) 281 282 Convey("signed url error", func(c C) { 283 signedURLSever := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 284 if strings.HasSuffix(r.URL.String(), "err") { 285 w.WriteHeader(http.StatusInternalServerError) 286 _, err := w.Write([]byte("internal error")) 287 c.So(err, ShouldBeNil) 288 return 289 } 290 buf := &bytes.Buffer{} 291 gw := gzip.NewWriter(buf) 292 _, err := gw.Write([]byte("large content")) 293 c.So(err, ShouldBeNil) 294 c.So(gw.Close(), ShouldBeNil) 295 w.Header().Set("Content-Encoding", "gzip") 296 _, err = w.Write(buf.Bytes()) 297 c.So(err, ShouldBeNil) 298 })) 299 defer signedURLSever.Close() 300 301 mockClient.EXPECT().GetProjectConfigs(gomock.Any(), proto.MatcherEqual(&pb.GetProjectConfigsRequest{ 302 Path: "config.cfg", 303 }), grpc.UseCompressor(grpcGzip.Name)).Return(&pb.GetProjectConfigsResponse{ 304 Configs: []*pb.Config{ 305 { 306 ConfigSet: "projects/project1", 307 Path: "config.cfg", 308 Content: &pb.Config_SignedUrl{ 309 SignedUrl: signedURLSever.URL, 310 }, 311 }, 312 { 313 ConfigSet: "projects/project2", 314 Path: "config.cfg", 315 Content: &pb.Config_SignedUrl{ 316 SignedUrl: signedURLSever.URL + "/err", 317 }, 318 }, 319 }, 320 }, nil) 321 322 configs, err := v2Impl.GetProjectConfigs(ctx, "config.cfg", false) 323 So(configs, ShouldBeNil) 324 So(err, ShouldErrLike, `for file(config.cfg) in config_set(projects/project2): failed to download file, got http response code: 500, body: "internal error"`) 325 So(transient.Tag.In(err), ShouldBeTrue) 326 }) 327 }) 328 329 Convey("GetProjects", func() { 330 Convey("ok", func() { 331 res := &pb.ListConfigSetsResponse{ 332 ConfigSets: []*pb.ConfigSet{ 333 { 334 Name: "projects/project1", 335 Url: "https://a.googlesource.com/project1", 336 }, 337 { 338 Name: "projects/project2", 339 Url: "https://b.googlesource.com/project2", 340 }, 341 }, 342 } 343 mockClient.EXPECT().ListConfigSets(gomock.Any(), proto.MatcherEqual(&pb.ListConfigSetsRequest{ 344 Domain: pb.ListConfigSetsRequest_PROJECT, 345 })).Return(res, nil) 346 347 projects, err := v2Impl.GetProjects(ctx) 348 So(err, ShouldBeNil) 349 350 url1, err := url.Parse(res.ConfigSets[0].Url) 351 So(err, ShouldBeNil) 352 url2, err := url.Parse(res.ConfigSets[1].Url) 353 So(err, ShouldBeNil) 354 So(projects, ShouldResemble, []config.Project{ 355 { 356 ID: "project1", 357 Name: "project1", 358 RepoType: config.GitilesRepo, 359 RepoURL: url1, 360 }, 361 { 362 ID: "project2", 363 Name: "project2", 364 RepoType: config.GitilesRepo, 365 RepoURL: url2, 366 }, 367 }) 368 }) 369 370 Convey("rpc err", func() { 371 mockClient.EXPECT().ListConfigSets(gomock.Any(), proto.MatcherEqual(&pb.ListConfigSetsRequest{ 372 Domain: pb.ListConfigSetsRequest_PROJECT, 373 })).Return(nil, status.Errorf(codes.Internal, "server internal error")) 374 375 projects, err := v2Impl.GetProjects(ctx) 376 So(projects, ShouldBeNil) 377 So(err, ShouldHaveGRPCStatus, codes.Internal, "server internal error") 378 So(transient.Tag.In(err), ShouldBeTrue) 379 }) 380 }) 381 382 Convey("ListFiles", func() { 383 Convey("ok", func() { 384 mockClient.EXPECT().GetConfigSet(gomock.Any(), proto.MatcherEqual(&pb.GetConfigSetRequest{ 385 ConfigSet: "projects/project", 386 Fields: &field_mask.FieldMask{ 387 Paths: []string{"configs"}, 388 }, 389 })).Return(&pb.ConfigSet{ 390 Configs: []*pb.Config{ 391 {Path: "file1"}, 392 {Path: "file2"}, 393 }, 394 }, nil) 395 396 files, err := v2Impl.ListFiles(ctx, config.Set("projects/project")) 397 So(err, ShouldBeNil) 398 So(files, ShouldResemble, []string{"file1", "file2"}) 399 }) 400 401 Convey("rpc err", func() { 402 mockClient.EXPECT().GetConfigSet(gomock.Any(), proto.MatcherEqual(&pb.GetConfigSetRequest{ 403 ConfigSet: "projects/project", 404 Fields: &field_mask.FieldMask{ 405 Paths: []string{"configs"}, 406 }, 407 })).Return(nil, status.Errorf(codes.Internal, "server internal error")) 408 409 files, err := v2Impl.ListFiles(ctx, config.Set("projects/project")) 410 So(files, ShouldBeNil) 411 So(err, ShouldHaveGRPCStatus, codes.Internal, "server internal error") 412 So(transient.Tag.In(err), ShouldBeTrue) 413 }) 414 }) 415 416 Convey("GetConfigs", func() { 417 Convey("listing err", func() { 418 mockClient.EXPECT().GetConfigSet(gomock.Any(), proto.MatcherEqual(&pb.GetConfigSetRequest{ 419 ConfigSet: "projects/project", 420 Fields: &field_mask.FieldMask{ 421 Paths: []string{"configs"}, 422 }, 423 })).Return(nil, status.Errorf(codes.NotFound, "no config set")) 424 files, err := v2Impl.GetConfigs(ctx, "projects/project", nil, false) 425 So(files, ShouldBeNil) 426 So(err, ShouldEqual, config.ErrNoConfig) 427 }) 428 429 Convey("listing ok", func() { 430 mockClient.EXPECT().GetConfigSet(gomock.Any(), proto.MatcherEqual(&pb.GetConfigSetRequest{ 431 ConfigSet: "projects/project", 432 Fields: &field_mask.FieldMask{ 433 Paths: []string{"configs"}, 434 }, 435 })).Return(&pb.ConfigSet{ 436 Configs: []*pb.Config{ 437 { 438 ConfigSet: "projects/project", 439 Path: "file1", 440 ContentSha256: "file1-hash", 441 Size: 123, 442 Revision: "rev", 443 Url: "file1-url", 444 }, 445 { 446 ConfigSet: "projects/project", 447 Path: "ignored", 448 Revision: "rev", 449 }, 450 { 451 ConfigSet: "projects/project", 452 Path: "file2", 453 ContentSha256: "file2-hash", 454 Size: 456, 455 Revision: "rev", 456 Url: "file2-url", 457 }, 458 }, 459 }, nil) 460 461 filter := func(path string) bool { return path != "ignored" } 462 463 expectedOutput := func(metaOnly bool) map[string]config.Config { 464 content := func(p string) string { return "" } 465 if !metaOnly { 466 content = func(p string) string { return p + " content" } 467 } 468 return map[string]config.Config{ 469 "file1": { 470 Meta: config.Meta{ 471 ConfigSet: "projects/project", 472 Path: "file1", 473 ContentHash: "file1-hash", 474 Revision: "rev", 475 ViewURL: "file1-url", 476 }, 477 Content: content("file1"), 478 }, 479 "file2": { 480 Meta: config.Meta{ 481 ConfigSet: "projects/project", 482 Path: "file2", 483 ContentHash: "file2-hash", 484 Revision: "rev", 485 ViewURL: "file2-url", 486 }, 487 Content: content("file2"), 488 }, 489 } 490 } 491 492 expectGetConfigCall := func(hash string, err error, cfg *pb.Config) { 493 if cfg != nil { 494 cfg.ConfigSet = "ignore-me" 495 cfg.Path = "ignore-me" 496 cfg.Revision = "ignore-me" 497 cfg.ContentSha256 = "ignore-me" 498 cfg.Url = "ignore-me" 499 } 500 mockClient.EXPECT().GetConfig(gomock.Any(), proto.MatcherEqual(&pb.GetConfigRequest{ 501 ConfigSet: "projects/project", 502 ContentSha256: hash, 503 }), grpc.UseCompressor(grpcGzip.Name)).Return(cfg, err) 504 } 505 506 Convey("meta only", func() { 507 files, err := v2Impl.GetConfigs(ctx, "projects/project", filter, true) 508 So(err, ShouldBeNil) 509 So(files, ShouldResemble, expectedOutput(true)) 510 }) 511 512 Convey("small bodies", func() { 513 expectGetConfigCall("file1-hash", nil, &pb.Config{ 514 Content: &pb.Config_RawContent{ 515 RawContent: []byte("file1 content"), 516 }, 517 }) 518 expectGetConfigCall("file2-hash", nil, &pb.Config{ 519 Content: &pb.Config_RawContent{ 520 RawContent: []byte("file2 content"), 521 }, 522 }) 523 524 files, err := v2Impl.GetConfigs(ctx, "projects/project", filter, false) 525 So(err, ShouldBeNil) 526 So(files, ShouldResemble, expectedOutput(false)) 527 }) 528 529 Convey("single fetch err", func() { 530 expectGetConfigCall("file1-hash", nil, &pb.Config{ 531 Content: &pb.Config_RawContent{ 532 RawContent: []byte("file1 content"), 533 }, 534 }) 535 expectGetConfigCall("file2-hash", 536 status.Errorf(codes.Internal, "server internal error"), nil) 537 538 files, err := v2Impl.GetConfigs(ctx, "projects/project", filter, false) 539 So(files, ShouldBeNil) 540 So(err, ShouldHaveGRPCStatus, codes.Internal, "server internal error") 541 So(transient.Tag.In(err), ShouldBeTrue) 542 }) 543 544 Convey("single fetch unexpectedly missing", func() { 545 expectGetConfigCall("file1-hash", nil, &pb.Config{ 546 Content: &pb.Config_RawContent{ 547 RawContent: []byte("file1 content"), 548 }, 549 }) 550 expectGetConfigCall("file2-hash", 551 status.Errorf(codes.NotFound, "gone but why"), nil) 552 553 files, err := v2Impl.GetConfigs(ctx, "projects/project", filter, false) 554 So(files, ShouldBeNil) 555 So(err, ShouldErrLike, "is unexpectedly gone") 556 So(transient.Tag.In(err), ShouldBeFalse) 557 }) 558 559 Convey("large body - ok", func(c C) { 560 signedURLServer := signedURLServer(c, "file2 content") 561 defer signedURLServer.Close() 562 563 expectGetConfigCall("file1-hash", nil, &pb.Config{ 564 Content: &pb.Config_RawContent{ 565 RawContent: []byte("file1 content"), 566 }, 567 }) 568 expectGetConfigCall("file2-hash", nil, &pb.Config{ 569 Content: &pb.Config_SignedUrl{ 570 SignedUrl: signedURLServer.URL, 571 }, 572 }) 573 574 files, err := v2Impl.GetConfigs(ctx, "projects/project", filter, false) 575 So(err, ShouldBeNil) 576 So(files, ShouldResemble, expectedOutput(false)) 577 }) 578 579 Convey("large body - err", func(c C) { 580 signedURLServer := signedURLServer(c, "file2 content") 581 defer signedURLServer.Close() 582 583 expectGetConfigCall("file1-hash", nil, &pb.Config{ 584 Content: &pb.Config_RawContent{ 585 RawContent: []byte("file1 content"), 586 }, 587 }) 588 expectGetConfigCall("file2-hash", nil, &pb.Config{ 589 Content: &pb.Config_SignedUrl{ 590 SignedUrl: signedURLServer.URL + "/err", 591 }, 592 }) 593 594 files, err := v2Impl.GetConfigs(ctx, "projects/project", filter, false) 595 So(files, ShouldBeNil) 596 So(err, ShouldErrLike, `fetching "file2" from signed URL: failed to download file`) 597 So(transient.Tag.In(err), ShouldBeTrue) 598 }) 599 }) 600 }) 601 }) 602 } 603 604 func signedURLServer(c C, body string) *httptest.Server { 605 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 606 if strings.HasSuffix(r.URL.String(), "err") { 607 w.WriteHeader(http.StatusInternalServerError) 608 _, err := w.Write([]byte(body)) 609 c.So(err, ShouldBeNil) 610 return 611 } 612 buf := &bytes.Buffer{} 613 gw := gzip.NewWriter(buf) 614 _, err := gw.Write([]byte(body)) 615 c.So(err, ShouldBeNil) 616 c.So(gw.Close(), ShouldBeNil) 617 w.Header().Set("Content-Encoding", "gzip") 618 _, err = w.Write(buf.Bytes()) 619 c.So(err, ShouldBeNil) 620 })) 621 }