go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/lucicfg/configset_test.go (about) 1 // Copyright 2019 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 lucicfg 16 17 import ( 18 "bytes" 19 "context" 20 "encoding/base64" 21 "fmt" 22 "io" 23 "net/http" 24 "net/http/httptest" 25 "os" 26 "path/filepath" 27 "sort" 28 "strings" 29 "sync" 30 "testing" 31 32 "github.com/golang/mock/gomock" 33 "github.com/klauspost/compress/gzip" 34 "google.golang.org/grpc/codes" 35 "google.golang.org/grpc/status" 36 37 legacy_config "go.chromium.org/luci/common/api/luci_config/config/v1" 38 "go.chromium.org/luci/common/proto" 39 "go.chromium.org/luci/common/proto/config" 40 configpb "go.chromium.org/luci/config_service/proto" 41 42 . "github.com/smartystreets/goconvey/convey" 43 . "go.chromium.org/luci/common/testing/assertions" 44 ) 45 46 func TestConfigSet(t *testing.T) { 47 t.Parallel() 48 49 ctx := context.Background() 50 51 Convey("With temp dir", t, func() { 52 tmp := t.TempDir() 53 path := func(p ...string) string { 54 return filepath.Join(append([]string{tmp}, p...)...) 55 } 56 57 So(os.Mkdir(path("subdir"), 0700), ShouldBeNil) 58 So(os.WriteFile(path("a.cfg"), []byte("a\n"), 0600), ShouldBeNil) 59 So(os.WriteFile(path("subdir", "b.cfg"), []byte("b\n"), 0600), ShouldBeNil) 60 61 Convey("Reading", func() { 62 Convey("Success", func() { 63 cfg, err := ReadConfigSet(tmp, "set name") 64 So(err, ShouldBeNil) 65 So(cfg, ShouldResemble, ConfigSet{ 66 Name: "set name", 67 Data: map[string][]byte{ 68 "a.cfg": []byte("a\n"), 69 "subdir/b.cfg": []byte("b\n"), 70 }, 71 }) 72 73 So(cfg.Files(), ShouldResemble, []string{ 74 "a.cfg", 75 "subdir/b.cfg", 76 }) 77 }) 78 79 Convey("Missing dir", func() { 80 _, err := ReadConfigSet(path("unknown"), "zzz") 81 So(err, ShouldNotBeNil) 82 }) 83 }) 84 }) 85 86 Convey("Validation", t, func() { 87 const configSetName = "config set name" 88 89 validator := testValidator{ 90 res: []*config.ValidationResult_Message{ 91 {Severity: config.ValidationResult_ERROR, Text: "Boo"}, 92 }, 93 } 94 95 cfgSet := ConfigSet{ 96 Name: configSetName, 97 Data: map[string][]byte{ 98 "a.cfg": []byte("aaa"), 99 "b.cfg": {0, 1, 2}, 100 }, 101 } 102 103 So(cfgSet.Validate(ctx, &validator), ShouldResemble, &ValidationResult{ 104 ConfigSet: configSetName, 105 Messages: validator.res, 106 }) 107 108 So(validator.cs, ShouldResemble, cfgSet) 109 }) 110 111 Convey("RPC error", t, func() { 112 validator := testValidator{ 113 err: fmt.Errorf("BOOM"), 114 } 115 116 cfg := ConfigSet{ 117 Name: "set", 118 Data: map[string][]byte{"a.cfg": []byte("aaa")}, 119 } 120 121 res := cfg.Validate(ctx, &validator) 122 So(res, ShouldResemble, &ValidationResult{ 123 ConfigSet: "set", 124 Failed: true, 125 RPCError: "BOOM", 126 }) 127 128 // This is considered overall failure. 129 err := res.OverallError(false) 130 So(err, ShouldErrLike, "BOOM") 131 So(res.Failed, ShouldBeTrue) 132 }) 133 134 Convey("Overall error check", t, func() { 135 result := func(level ...config.ValidationResult_Severity) *ValidationResult { 136 res := &ValidationResult{} 137 for _, l := range level { 138 res.Messages = append(res.Messages, &config.ValidationResult_Message{ 139 Severity: l, 140 Text: "boo", 141 }) 142 } 143 return res 144 } 145 146 // Fail on warnings = false. 147 So(result().OverallError(false), ShouldBeNil) 148 So(result(config.ValidationResult_INFO, config.ValidationResult_WARNING).OverallError(false), ShouldBeNil) 149 So(result(config.ValidationResult_INFO, config.ValidationResult_ERROR).OverallError(false), ShouldErrLike, "some files were invalid") 150 151 // Fail on warnings = true. 152 So(result().OverallError(true), ShouldBeNil) 153 So(result(config.ValidationResult_INFO).OverallError(true), ShouldBeNil) 154 So(result(config.ValidationResult_INFO, config.ValidationResult_WARNING, config.ValidationResult_ERROR).OverallError(true), ShouldErrLike, "some files were invalid") 155 So(result(config.ValidationResult_INFO, config.ValidationResult_WARNING).OverallError(true), ShouldErrLike, "some files had validation warnings") 156 }) 157 } 158 159 func TestRemoteValidator(t *testing.T) { 160 t.Parallel() 161 162 Convey("Remote Validator", t, func(c C) { 163 ctx := context.Background() 164 ctrl := gomock.NewController(t) 165 mockClient := configpb.NewMockConfigsClient(ctrl) 166 167 validator := &remoteValidator{ 168 cfgClient: mockClient, 169 } 170 171 cs := ConfigSet{ 172 Name: "example-proj", 173 Data: map[string][]byte{ 174 "foo.cfg": []byte("This is the config content"), 175 }, 176 } 177 validationMsgs := []*config.ValidationResult_Message{ 178 { 179 Path: "foo.cfg", 180 Severity: config.ValidationResult_ERROR, 181 Text: "bad config syntax", 182 }, 183 } 184 185 Convey("empty config set", func() { 186 cs.Data = nil 187 res, err := validator.Validate(ctx, cs) 188 So(err, ShouldBeNil) 189 So(res, ShouldBeEmpty) 190 }) 191 Convey("successfully validated", func() { 192 mockClient.EXPECT().ValidateConfigs(gomock.Any(), proto.MatcherEqual(&configpb.ValidateConfigsRequest{ 193 ConfigSet: cs.Name, 194 FileHashes: []*configpb.ValidateConfigsRequest_FileHash{ 195 { 196 Path: "foo.cfg", 197 Sha256: "fd243f0466e35bcc9146b012415e11c88627a2a7c31a7fb121c5a8e99401e417", 198 }, 199 }, 200 })).Return(&config.ValidationResult{ 201 Messages: validationMsgs, 202 }, nil) 203 res, err := validator.Validate(ctx, cs) 204 So(err, ShouldBeNil) 205 So(res, ShouldResembleProto, validationMsgs) 206 }) 207 Convey("unvalidatable files", func() { 208 cs.Data["unvalidatable.cfg"] = []byte("some content") 209 st, err := status.New(codes.InvalidArgument, "invalid validate config request").WithDetails(&configpb.BadValidationRequestFixInfo{ 210 UnvalidatableFiles: []string{"unvalidatable.cfg"}, 211 }) 212 So(err, ShouldBeNil) 213 mockClient.EXPECT().ValidateConfigs(gomock.Any(), proto.MatcherEqual(&configpb.ValidateConfigsRequest{ 214 ConfigSet: cs.Name, 215 FileHashes: []*configpb.ValidateConfigsRequest_FileHash{ 216 { 217 Path: "foo.cfg", 218 Sha256: "fd243f0466e35bcc9146b012415e11c88627a2a7c31a7fb121c5a8e99401e417", 219 }, 220 { 221 Path: "unvalidatable.cfg", 222 Sha256: "290f493c44f5d63d06b374d0a5abd292fae38b92cab2fae5efefe1b0e9347f56", 223 }, 224 }, 225 })).Return(nil, st.Err()) 226 // Retry after stripping out `unvalidatable.cfg` 227 mockClient.EXPECT().ValidateConfigs(gomock.Any(), proto.MatcherEqual(&configpb.ValidateConfigsRequest{ 228 ConfigSet: cs.Name, 229 FileHashes: []*configpb.ValidateConfigsRequest_FileHash{ 230 { 231 Path: "foo.cfg", 232 Sha256: "fd243f0466e35bcc9146b012415e11c88627a2a7c31a7fb121c5a8e99401e417", 233 }, 234 }, 235 })).Return(&config.ValidationResult{ 236 Messages: validationMsgs, 237 }, nil) 238 res, err := validator.Validate(ctx, cs) 239 So(err, ShouldBeNil) 240 So(res, ShouldResembleProto, validationMsgs) 241 }) 242 Convey("upload files", func(c C) { 243 Convey("succeed", func() { 244 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 245 c.So(r.Method, ShouldEqual, http.MethodPut) 246 c.So(r.Header.Get("Content-Encoding"), ShouldEqual, "gzip") 247 c.So(r.Header.Get("x-goog-content-length-range"), ShouldEqual, "0,10240") 248 compressed, err := io.ReadAll(r.Body) 249 c.So(err, ShouldBeNil) 250 reader, err := gzip.NewReader(bytes.NewBuffer(compressed)) 251 c.So(err, ShouldBeNil) 252 config, err := io.ReadAll(reader) 253 c.So(err, ShouldBeNil) 254 c.So(string(config), ShouldEqual, "This is the config content") 255 w.WriteHeader(http.StatusOK) 256 })) 257 defer ts.Close() 258 st, err := status.New(codes.InvalidArgument, "invalid validate config request").WithDetails(&configpb.BadValidationRequestFixInfo{ 259 UploadFiles: []*configpb.BadValidationRequestFixInfo_UploadFile{ 260 { 261 Path: "foo.cfg", 262 SignedUrl: ts.URL, 263 MaxConfigSize: 10240, 264 }, 265 }, 266 }) 267 So(err, ShouldBeNil) 268 mockClient.EXPECT().ValidateConfigs(gomock.Any(), proto.MatcherEqual(&configpb.ValidateConfigsRequest{ 269 ConfigSet: cs.Name, 270 FileHashes: []*configpb.ValidateConfigsRequest_FileHash{ 271 { 272 Path: "foo.cfg", 273 Sha256: "fd243f0466e35bcc9146b012415e11c88627a2a7c31a7fb121c5a8e99401e417", 274 }, 275 }, 276 })).Return(nil, st.Err()). 277 Return(&config.ValidationResult{ 278 Messages: validationMsgs, 279 }, nil) 280 res, err := validator.Validate(ctx, cs) 281 So(err, ShouldBeNil) 282 So(res, ShouldResembleProto, validationMsgs) 283 }) 284 285 Convey("no file unvalidatable", func() { 286 cs.Data = map[string][]byte{ 287 "unvalidatable.cfg": []byte("some content"), 288 } 289 st, err := status.New(codes.InvalidArgument, "invalid validate config request").WithDetails(&configpb.BadValidationRequestFixInfo{ 290 UnvalidatableFiles: []string{"unvalidatable.cfg"}, 291 }) 292 So(err, ShouldBeNil) 293 mockClient.EXPECT().ValidateConfigs(gomock.Any(), proto.MatcherEqual(&configpb.ValidateConfigsRequest{ 294 ConfigSet: cs.Name, 295 FileHashes: []*configpb.ValidateConfigsRequest_FileHash{ 296 { 297 Path: "unvalidatable.cfg", 298 Sha256: "290f493c44f5d63d06b374d0a5abd292fae38b92cab2fae5efefe1b0e9347f56", 299 }, 300 }, 301 })).Return(nil, st.Err()) 302 res, err := validator.Validate(ctx, cs) 303 So(err, ShouldBeNil) 304 So(res, ShouldBeEmpty) 305 }) 306 Convey("failed", func() { 307 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 308 w.WriteHeader(http.StatusBadRequest) 309 fmt.Fprintf(w, "config is too large") 310 })) 311 defer ts.Close() 312 st, err := status.New(codes.InvalidArgument, "invalid validate config request").WithDetails(&configpb.BadValidationRequestFixInfo{ 313 UploadFiles: []*configpb.BadValidationRequestFixInfo_UploadFile{ 314 { 315 Path: "foo.cfg", 316 SignedUrl: ts.URL, 317 MaxConfigSize: 10240, 318 }, 319 }, 320 }) 321 So(err, ShouldBeNil) 322 mockClient.EXPECT().ValidateConfigs(gomock.Any(), proto.MatcherEqual(&configpb.ValidateConfigsRequest{ 323 ConfigSet: cs.Name, 324 FileHashes: []*configpb.ValidateConfigsRequest_FileHash{ 325 { 326 Path: "foo.cfg", 327 Sha256: "fd243f0466e35bcc9146b012415e11c88627a2a7c31a7fb121c5a8e99401e417", 328 }, 329 }, 330 })).Return(nil, st.Err()) 331 res, err := validator.Validate(ctx, cs) 332 So(err, ShouldErrLike, "failed to upload file") 333 So(res, ShouldBeEmpty) 334 }) 335 }) 336 Convey("failed to call LUCI Config", func() { 337 mockClient.EXPECT().ValidateConfigs(gomock.Any(), proto.MatcherEqual(&configpb.ValidateConfigsRequest{ 338 ConfigSet: cs.Name, 339 FileHashes: []*configpb.ValidateConfigsRequest_FileHash{ 340 { 341 Path: "foo.cfg", 342 Sha256: "fd243f0466e35bcc9146b012415e11c88627a2a7c31a7fb121c5a8e99401e417", 343 }, 344 }, 345 })).Return(nil, status.Error(codes.InvalidArgument, "invalid validate config request")) 346 res, err := validator.Validate(ctx, cs) 347 So(err, ShouldErrLike, "failed to call LUCI Config") 348 So(res, ShouldBeEmpty) 349 }) 350 }) 351 } 352 353 func TestLegacyRemoteValidator(t *testing.T) { 354 t.Parallel() 355 356 ctx := context.Background() 357 358 mustBase64Decode := func(s string) []byte { 359 ret, err := base64.StdEncoding.DecodeString(s) 360 if err != nil { 361 panic(err) 362 } 363 return ret 364 } 365 366 cfgSet := ConfigSet{ 367 Name: "config-set", 368 Data: map[string][]byte{ 369 // "aaaaaaaa", "bbbb", "cccccccccccc", "dddddddd" will be the actual 370 // content send to LUCI Config 371 "a.cfg": mustBase64Decode("aaaaaaaa"), 372 "b.cfg": mustBase64Decode("bbbb"), 373 "c.cfg": mustBase64Decode("cccccccccccc"), 374 "d.cfg": mustBase64Decode("dddddddd"), 375 }, 376 } 377 378 Convey("Splits requests, collects messages", t, func() { 379 var reqs []*legacy_config.LuciConfigValidateConfigRequestMessage 380 var lock sync.Mutex 381 382 val := &legacyRemoteValidator{ 383 requestSizeLimitBytes: 12, 384 validateConfig: func(ctx context.Context, req *legacy_config.LuciConfigValidateConfigRequestMessage) (*legacy_config.LuciConfigValidateConfigResponseMessage, error) { 385 lock.Lock() 386 reqs = append(reqs, req) 387 lock.Unlock() 388 389 if req.ConfigSet != "config-set" { 390 panic("bad ConfigSet") 391 } 392 393 var messages []*legacy_config.ComponentsConfigEndpointValidationMessage 394 for _, f := range req.Files { 395 messages = append(messages, &legacy_config.ComponentsConfigEndpointValidationMessage{ 396 Path: f.Path, 397 Severity: "ERROR", 398 Text: fmt.Sprintf("Boom in %s", f.Path), 399 }) 400 } 401 402 return &legacy_config.LuciConfigValidateConfigResponseMessage{ 403 Messages: messages, 404 }, nil 405 }, 406 } 407 408 msg, err := val.Validate(ctx, cfgSet) 409 So(err, ShouldBeNil) 410 So(msg, ShouldResembleProto, []*config.ValidationResult_Message{ 411 {Path: "a.cfg", Severity: config.ValidationResult_ERROR, Text: "Boom in a.cfg"}, 412 {Path: "b.cfg", Severity: config.ValidationResult_ERROR, Text: "Boom in b.cfg"}, 413 {Path: "c.cfg", Severity: config.ValidationResult_ERROR, Text: "Boom in c.cfg"}, 414 {Path: "d.cfg", Severity: config.ValidationResult_ERROR, Text: "Boom in d.cfg"}, 415 }) 416 417 var sets []string 418 for _, req := range reqs { 419 var reqSize int 420 var names []string 421 for _, f := range req.Files { 422 reqSize += len(f.Content) 423 names = append(names, f.Path) 424 } 425 sets = append(sets, strings.Join(names, "+")) 426 So(reqSize, ShouldBeLessThanOrEqualTo, val.requestSizeLimitBytes) 427 } 428 sort.Strings(sets) 429 So(sets, ShouldResemble, []string{"b.cfg+a.cfg", "c.cfg", "d.cfg"}) 430 Convey("Single file too large", func() { 431 val.requestSizeLimitBytes = 10 432 _, err := val.Validate(ctx, cfgSet) 433 So(err, ShouldErrLike, "the size of file \"c.cfg\" is 12") 434 }) 435 }) 436 437 Convey("Handles errors", t, func() { 438 val := &legacyRemoteValidator{ 439 requestSizeLimitBytes: 12, 440 validateConfig: func(ctx context.Context, req *legacy_config.LuciConfigValidateConfigRequestMessage) (*legacy_config.LuciConfigValidateConfigResponseMessage, error) { 441 if req.ConfigSet != "config-set" { 442 panic("bad ConfigSet") 443 } 444 445 var messages []*legacy_config.ComponentsConfigEndpointValidationMessage 446 for _, f := range req.Files { 447 if f.Path == "c.cfg" || f.Path == "d.cfg" { 448 return nil, fmt.Errorf("fake error") 449 } 450 messages = append(messages, &legacy_config.ComponentsConfigEndpointValidationMessage{ 451 Path: f.Path, 452 Severity: "ERROR", 453 Text: fmt.Sprintf("Boom in %s", f.Path), 454 }) 455 } 456 457 return &legacy_config.LuciConfigValidateConfigResponseMessage{ 458 Messages: messages, 459 }, nil 460 }, 461 } 462 463 msg, err := val.Validate(ctx, cfgSet) 464 So(err, ShouldNotBeNil) 465 So(err.Error(), ShouldEqual, "fake error (and 1 other error)") 466 So(msg, ShouldResembleProto, []*config.ValidationResult_Message{ 467 {Path: "a.cfg", Severity: config.ValidationResult_ERROR, Text: "Boom in a.cfg"}, 468 {Path: "b.cfg", Severity: config.ValidationResult_ERROR, Text: "Boom in b.cfg"}, 469 }) 470 }) 471 } 472 473 type testValidator struct { 474 cs ConfigSet // captured config set 475 res []*config.ValidationResult_Message // a reply to send 476 err error // an RPC error 477 } 478 479 func (t *testValidator) Validate(ctx context.Context, cs ConfigSet) ([]*config.ValidationResult_Message, error) { 480 t.cs = cs 481 return t.res, t.err 482 }