go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/configs/validation/project_test.go (about) 1 // Copyright 2018 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 validation 16 17 import ( 18 "context" 19 "fmt" 20 "testing" 21 22 "google.golang.org/protobuf/encoding/prototext" 23 "google.golang.org/protobuf/proto" 24 "google.golang.org/protobuf/types/known/durationpb" 25 26 "go.chromium.org/luci/config/validation" 27 28 cfgpb "go.chromium.org/luci/cv/api/config/v2" 29 apipb "go.chromium.org/luci/cv/api/v1" 30 "go.chromium.org/luci/cv/internal/configs/srvcfg" 31 "go.chromium.org/luci/cv/internal/cvtesting" 32 listenerpb "go.chromium.org/luci/cv/settings/listener" 33 34 . "github.com/smartystreets/goconvey/convey" 35 . "go.chromium.org/luci/common/testing/assertions" 36 ) 37 38 func mockListenerSettings(ctx context.Context, hosts ...string) error { 39 var subs []*listenerpb.Settings_GerritSubscription 40 for _, h := range hosts { 41 subs = append(subs, &listenerpb.Settings_GerritSubscription{Host: h}) 42 } 43 return srvcfg.SetTestListenerConfig(ctx, &listenerpb.Settings{GerritSubscriptions: subs}, nil) 44 } 45 46 func TestValidateProjectHighLevel(t *testing.T) { 47 t.Parallel() 48 const project = "proj" 49 50 Convey("ValidateProject works", t, func() { 51 ct := cvtesting.Test{} 52 ctx, cancel := ct.SetUp(t) 53 defer cancel() 54 55 cfg := cfgpb.Config{} 56 vctx := &validation.Context{Context: ctx} 57 So(prototext.Unmarshal([]byte(validConfigTextPB), &cfg), ShouldBeNil) 58 So(mockListenerSettings(ctx, "chromium-review.googlesource.com"), ShouldBeNil) 59 60 Convey("OK", func() { 61 So(ValidateProject(vctx, &cfg, project), ShouldBeNil) 62 So(vctx.Finalize(), ShouldBeNil) 63 }) 64 Convey("Error", func() { 65 cfg.GetConfigGroups()[0].Name = "!invalid! name" 66 So(ValidateProject(vctx, &cfg, project), ShouldBeNil) 67 So(vctx.Finalize(), ShouldErrLike, "must match") 68 }) 69 }) 70 71 Convey("ValidateProjectConfig works", t, func() { 72 ct := cvtesting.Test{} 73 ctx, cancel := ct.SetUp(t) 74 defer cancel() 75 76 cfg := cfgpb.Config{} 77 vctx := &validation.Context{Context: ctx} 78 So(prototext.Unmarshal([]byte(validConfigTextPB), &cfg), ShouldBeNil) 79 80 Convey("OK", func() { 81 So(ValidateProjectConfig(vctx, &cfg), ShouldBeNil) 82 So(vctx.Finalize(), ShouldBeNil) 83 }) 84 Convey("Error", func() { 85 cfg.GetConfigGroups()[0].Name = "!invalid! name" 86 So(ValidateProject(vctx, &cfg, project), ShouldBeNil) 87 So(vctx.Finalize(), ShouldErrLike, "must match") 88 }) 89 }) 90 } 91 92 const validConfigTextPB = ` 93 cq_status_host: "chromium-cq-status.appspot.com" 94 submit_options { 95 max_burst: 2 96 burst_delay { seconds: 120 } 97 } 98 config_groups { 99 name: "test" 100 gerrit { 101 url: "https://chromium-review.googlesource.com" 102 projects { 103 name: "chromium/src" 104 ref_regexp: "refs/heads/.+" 105 ref_regexp_exclude: "refs/heads/excluded" 106 } 107 } 108 verifiers { 109 tree_status { url: "https://chromium-status.appspot.com" } 110 gerrit_cq_ability { committer_list: "project-chromium-committers" } 111 tryjob { 112 retry_config { 113 single_quota: 1 114 global_quota: 2 115 failure_weight: 1 116 transient_failure_weight: 1 117 timeout_weight: 1 118 } 119 builders { 120 name: "chromium/try/linux" 121 cancel_stale: NO 122 } 123 } 124 } 125 } 126 ` 127 128 func TestValidateProjectDetailed(t *testing.T) { 129 t.Parallel() 130 131 const ( 132 configSet = "projects/foo" 133 project = "foo" 134 path = "cq.cfg" 135 ) 136 137 Convey("Validate Config", t, func() { 138 ct := cvtesting.Test{} 139 ctx, cancel := ct.SetUp(t) 140 defer cancel() 141 vctx := &validation.Context{Context: ctx} 142 validateProjectConfig := func(vctx *validation.Context, cfg *cfgpb.Config) { 143 vd, err := makeProjectConfigValidator(vctx, project) 144 So(err, ShouldBeNil) 145 vd.validateProjectConfig(cfg) 146 } 147 148 Convey("Loading bad proto", func() { 149 content := []byte(` bad: "config" `) 150 So(validateProject(vctx, configSet, path, content), ShouldBeNil) 151 So(vctx.Finalize().Error(), ShouldContainSubstring, "unknown field") 152 }) 153 154 // It's easier to manipulate Go struct than text. 155 cfg := cfgpb.Config{} 156 So(prototext.Unmarshal([]byte(validConfigTextPB), &cfg), ShouldBeNil) 157 So(mockListenerSettings(ctx, "chromium-review.googlesource.com"), ShouldBeNil) 158 159 Convey("OK", func() { 160 Convey("good proto, good config", func() { 161 So(validateProject(vctx, configSet, path, []byte(validConfigTextPB)), ShouldBeNil) 162 So(vctx.Finalize(), ShouldBeNil) 163 }) 164 Convey("good config", func() { 165 validateProjectConfig(vctx, &cfg) 166 So(vctx.Finalize(), ShouldBeNil) 167 }) 168 }) 169 170 Convey("Missing gerrit subscription", func() { 171 // reset the listener settings to make the validation fail. 172 So(mockListenerSettings(ctx), ShouldBeNil) 173 174 Convey("validation fails", func() { 175 So(validateProject(vctx, configSet, path, []byte(validConfigTextPB)), ShouldBeNil) 176 So(vctx.Finalize(), ShouldErrLike, "Gerrit pub/sub") 177 }) 178 Convey("OK if the project is disabled in listener settings", func() { 179 ct.DisableProjectInGerritListener(ctx, project) 180 So(validateProject(vctx, configSet, path, []byte(validConfigTextPB)), ShouldBeNil) 181 }) 182 }) 183 So(mockListenerSettings(ctx, "chromium-review.googlesource.com"), ShouldBeNil) 184 185 Convey("Top-level config", func() { 186 Convey("Top level opts can be omitted", func() { 187 cfg.CqStatusHost = "" 188 cfg.SubmitOptions = nil 189 validateProjectConfig(vctx, &cfg) 190 So(vctx.Finalize(), ShouldBeNil) 191 }) 192 Convey("draining time not allowed crbug/1208569", func() { 193 cfg.DrainingStartTime = "2017-12-23T15:47:58Z" 194 validateProjectConfig(vctx, &cfg) 195 So(vctx.Finalize(), ShouldErrLike, `https://crbug.com/1208569`) 196 }) 197 Convey("CQ status host can be internal", func() { 198 cfg.CqStatusHost = CQStatusHostInternal 199 validateProjectConfig(vctx, &cfg) 200 So(vctx.Finalize(), ShouldBeNil) 201 }) 202 Convey("CQ status host can be empty", func() { 203 cfg.CqStatusHost = "" 204 validateProjectConfig(vctx, &cfg) 205 So(vctx.Finalize(), ShouldBeNil) 206 }) 207 Convey("CQ status host can be public", func() { 208 cfg.CqStatusHost = CQStatusHostPublic 209 validateProjectConfig(vctx, &cfg) 210 So(vctx.Finalize(), ShouldBeNil) 211 }) 212 Convey("CQ status host can not be something else", func() { 213 cfg.CqStatusHost = "nope.example.com" 214 validateProjectConfig(vctx, &cfg) 215 So(vctx.Finalize(), ShouldErrLike, "cq_status_host must be") 216 }) 217 Convey("Bad max_burst", func() { 218 cfg.SubmitOptions.MaxBurst = -1 219 validateProjectConfig(vctx, &cfg) 220 So(vctx.Finalize(), ShouldNotBeNil) 221 }) 222 Convey("Bad burst_delay ", func() { 223 cfg.SubmitOptions.BurstDelay.Seconds = -1 224 validateProjectConfig(vctx, &cfg) 225 So(vctx.Finalize(), ShouldNotBeNil) 226 }) 227 Convey("config_groups", func() { 228 orig := cfg.ConfigGroups[0] 229 add := func(refRegexps ...string) { 230 // Add new regexps sequence with constant valid gerrit url and 231 // project and the same valid verifiers. 232 cfg.ConfigGroups = append(cfg.ConfigGroups, &cfgpb.ConfigGroup{ 233 Name: fmt.Sprintf("group-%d", len(cfg.ConfigGroups)), 234 Gerrit: []*cfgpb.ConfigGroup_Gerrit{ 235 { 236 Url: orig.Gerrit[0].Url, 237 Projects: []*cfgpb.ConfigGroup_Gerrit_Project{ 238 { 239 Name: orig.Gerrit[0].Projects[0].Name, 240 RefRegexp: refRegexps, 241 }, 242 }, 243 }, 244 }, 245 Verifiers: orig.Verifiers, 246 }) 247 } 248 249 Convey("at least 1 Config Group", func() { 250 cfg.ConfigGroups = nil 251 validateProjectConfig(vctx, &cfg) 252 So(vctx.Finalize(), ShouldErrLike, "at least 1 config_group is required") 253 }) 254 255 Convey("at most 1 fallback", func() { 256 cfg.ConfigGroups = nil 257 add("refs/heads/.+") 258 cfg.ConfigGroups[0].Fallback = cfgpb.Toggle_YES 259 add("refs/branch-heads/.+") 260 cfg.ConfigGroups[1].Fallback = cfgpb.Toggle_YES 261 validateProjectConfig(vctx, &cfg) 262 So(vctx.Finalize(), ShouldErrLike, "At most 1 config_group with fallback=YES allowed") 263 }) 264 265 Convey("with unique names", func() { 266 cfg.ConfigGroups = nil 267 add("refs/heads/.+") 268 add("refs/branch-heads/.+") 269 add("refs/other-heads/.+") 270 Convey("dups not allowed", func() { 271 cfg.ConfigGroups[0].Name = "aaa" 272 cfg.ConfigGroups[1].Name = "bbb" 273 cfg.ConfigGroups[2].Name = "bbb" 274 validateProjectConfig(vctx, &cfg) 275 So(vctx.Finalize(), ShouldErrLike, "duplicate config_group name \"bbb\" not allowed") 276 }) 277 }) 278 }) 279 }) 280 281 Convey("ConfigGroups", func() { 282 Convey("with no Name", func() { 283 cfg.ConfigGroups[0].Name = "" 284 validateProjectConfig(vctx, &cfg) 285 So(mustError(vctx.Finalize()), ShouldErrLike, "name is required") 286 }) 287 Convey("with valid Name", func() { 288 cfg.ConfigGroups[0].Name = "!invalid!" 289 validateProjectConfig(vctx, &cfg) 290 So(mustError(vctx.Finalize()), ShouldErrLike, "name must match") 291 }) 292 Convey("with Gerrit", func() { 293 cfg.ConfigGroups[0].Gerrit = nil 294 validateProjectConfig(vctx, &cfg) 295 So(vctx.Finalize(), ShouldErrLike, "at least 1 gerrit is required") 296 }) 297 Convey("with Verifiers", func() { 298 cfg.ConfigGroups[0].Verifiers = nil 299 validateProjectConfig(vctx, &cfg) 300 So(vctx.Finalize(), ShouldErrLike, "verifiers are required") 301 }) 302 Convey("no dup Gerrit blocks", func() { 303 cfg.ConfigGroups[0].Gerrit = append(cfg.ConfigGroups[0].Gerrit, cfg.ConfigGroups[0].Gerrit[0]) 304 validateProjectConfig(vctx, &cfg) 305 So(vctx.Finalize(), ShouldErrLike, "duplicate gerrit url in the same config_group") 306 }) 307 Convey("CombineCLs", func() { 308 cfg.ConfigGroups[0].CombineCls = &cfgpb.CombineCLs{} 309 Convey("Needs stabilization_delay", func() { 310 validateProjectConfig(vctx, &cfg) 311 So(vctx.Finalize(), ShouldErrLike, "stabilization_delay is required") 312 }) 313 cfg.ConfigGroups[0].CombineCls.StabilizationDelay = &durationpb.Duration{} 314 Convey("Needs stabilization_delay > 10s", func() { 315 validateProjectConfig(vctx, &cfg) 316 So(vctx.Finalize(), ShouldErrLike, "stabilization_delay must be at least 10 seconds") 317 }) 318 cfg.ConfigGroups[0].CombineCls.StabilizationDelay.Seconds = 20 319 Convey("OK", func() { 320 validateProjectConfig(vctx, &cfg) 321 So(vctx.Finalize(), ShouldBeNil) 322 }) 323 Convey("Can't use with allow_submit_with_open_deps", func() { 324 cfg.ConfigGroups[0].Verifiers.GerritCqAbility.AllowSubmitWithOpenDeps = true 325 validateProjectConfig(vctx, &cfg) 326 So(vctx.Finalize(), ShouldErrLike, "allow_submit_with_open_deps=true") 327 }) 328 }) 329 330 mode := &cfgpb.Mode{ 331 Name: "TEST_RUN", 332 CqLabelValue: 1, 333 TriggeringLabel: "TEST_RUN_LABEL", 334 TriggeringValue: 2, 335 } 336 Convey("Mode", func() { 337 cfg.ConfigGroups[0].AdditionalModes = []*cfgpb.Mode{mode} 338 Convey("OK", func() { 339 validateProjectConfig(vctx, &cfg) 340 So(vctx.Finalize(), ShouldBeNil) 341 }) 342 Convey("name", func() { 343 Convey("empty", func() { mode.Name = "" }) 344 Convey("with invalid chars", func() { mode.Name = "~!Invalid Run Mode!~" }) 345 346 validateProjectConfig(vctx, &cfg) 347 So(vctx.Finalize(), ShouldErrLike, "does not match regex pattern") 348 }) 349 Convey("cq_label_value", func() { 350 Convey("with -1", func() { mode.CqLabelValue = -1 }) 351 Convey("with 0", func() { mode.CqLabelValue = 0 }) 352 Convey("with 3", func() { mode.CqLabelValue = 3 }) 353 Convey("with 10", func() { mode.CqLabelValue = 10 }) 354 355 validateProjectConfig(vctx, &cfg) 356 So(vctx.Finalize(), ShouldErrLike, "must be in list [1 2]") 357 }) 358 Convey("triggering_label", func() { 359 Convey("empty", func() { 360 mode.TriggeringLabel = "" 361 validateProjectConfig(vctx, &cfg) 362 So(vctx.Finalize(), ShouldErrLike, "length must be at least 1 runes") 363 }) 364 Convey("with Commit-Queue", func() { 365 mode.TriggeringLabel = "Commit-Queue" 366 validateProjectConfig(vctx, &cfg) 367 So(vctx.Finalize(), ShouldErrLike, "must not be in list [Commit-Queue]") 368 }) 369 }) 370 Convey("triggering_value", func() { 371 Convey("with 0", func() { mode.TriggeringValue = 0 }) 372 Convey("with -1", func() { mode.TriggeringValue = -1 }) 373 374 validateProjectConfig(vctx, &cfg) 375 So(vctx.Finalize(), ShouldErrLike, "must be greater than 0") 376 }) 377 }) 378 379 // Tests for additional mode specific verifiers. 380 Convey("additional_modes", func() { 381 cfg.ConfigGroups[0].AdditionalModes = []*cfgpb.Mode{mode} 382 Convey("duplicate names", func() { 383 cfg.ConfigGroups[0].AdditionalModes = []*cfgpb.Mode{mode, mode} 384 validateProjectConfig(vctx, &cfg) 385 So(vctx.Finalize(), ShouldErrLike, `"TEST_RUN" is already in use`) 386 }) 387 }) 388 389 Convey("post_actions", func() { 390 pa := &cfgpb.ConfigGroup_PostAction{ 391 Name: "CQ verified", 392 Action: &cfgpb.ConfigGroup_PostAction_VoteGerritLabels_{ 393 VoteGerritLabels: &cfgpb.ConfigGroup_PostAction_VoteGerritLabels{ 394 Votes: []*cfgpb.ConfigGroup_PostAction_VoteGerritLabels_Vote{ 395 { 396 Name: "CQ-verified", 397 Value: 1, 398 }, 399 }, 400 }, 401 }, 402 Conditions: []*cfgpb.ConfigGroup_PostAction_TriggeringCondition{ 403 { 404 Mode: "DRY_RUN", 405 Statuses: []apipb.Run_Status{apipb.Run_SUCCEEDED}, 406 }, 407 }, 408 } 409 cfg.ConfigGroups[0].PostActions = []*cfgpb.ConfigGroup_PostAction{pa} 410 411 Convey("works", func() { 412 validateProjectConfig(vctx, &cfg) 413 So(vctx.Finalize(), ShouldBeNil) 414 }) 415 416 Convey("name", func() { 417 Convey("missing", func() { 418 pa.Name = "" 419 validateProjectConfig(vctx, &cfg) 420 So(vctx.Finalize(), ShouldErrLike, "Name: value length must be at least 1") 421 }) 422 423 Convey("duplicate", func() { 424 cfg.ConfigGroups[0].PostActions = append(cfg.ConfigGroups[0].PostActions, 425 cfg.ConfigGroups[0].PostActions[0]) 426 validateProjectConfig(vctx, &cfg) 427 So(vctx.Finalize(), ShouldErrLike, `"CQ verified"' is already in use`) 428 }) 429 }) 430 431 Convey("action", func() { 432 Convey("missing", func() { 433 pa.Action = nil 434 validateProjectConfig(vctx, &cfg) 435 So(vctx.Finalize(), ShouldErrLike, `Action: value is required`) 436 }) 437 Convey("vote_gerrit_labels", func() { 438 w := pa.GetAction().(*cfgpb.ConfigGroup_PostAction_VoteGerritLabels_).VoteGerritLabels 439 Convey("empty pairs", func() { 440 w.Votes = nil 441 validateProjectConfig(vctx, &cfg) 442 So(vctx.Finalize(), ShouldErrLike, "Votes: value must contain") 443 }) 444 Convey("a pair with an empty name", func() { 445 w.Votes[0].Name = "" 446 validateProjectConfig(vctx, &cfg) 447 So(vctx.Finalize(), ShouldErrLike, "Name: value length must be") 448 }) 449 Convey("pairs with duplicate names", func() { 450 w.Votes = append(w.Votes, w.Votes[0]) 451 validateProjectConfig(vctx, &cfg) 452 So(vctx.Finalize(), ShouldErrLike, `"CQ-verified" already specified`) 453 454 }) 455 }) 456 }) 457 458 Convey("triggering_conditions", func() { 459 tc := pa.Conditions[0] 460 Convey("missing", func() { 461 pa.Conditions = nil 462 validateProjectConfig(vctx, &cfg) 463 So(vctx.Finalize(), ShouldErrLike, `Conditions: value must contain at least 1`) 464 }) 465 Convey("mode", func() { 466 Convey("missing", func() { 467 tc.Mode = "" 468 validateProjectConfig(vctx, &cfg) 469 So(vctx.Finalize(), ShouldErrLike, `Mode: value length must be at least 1`) 470 }) 471 472 cfg.ConfigGroups[0].AdditionalModes = []*cfgpb.Mode{mode} 473 Convey("with an existing additional mode", func() { 474 tc.Mode = mode.Name 475 validateProjectConfig(vctx, &cfg) 476 So(vctx.Finalize(), ShouldBeNil) 477 }) 478 479 Convey("with an non-existing additional mode", func() { 480 tc.Mode = "NON_EXISTING_RUN" 481 validateProjectConfig(vctx, &cfg) 482 So(vctx.Finalize(), ShouldErrLike, `invalid mode "NON_EXISTING_RUN"`) 483 }) 484 }) 485 Convey("statuses", func() { 486 Convey("missing", func() { 487 tc.Statuses = nil 488 validateProjectConfig(vctx, &cfg) 489 So(vctx.Finalize(), ShouldErrLike, `Statuses: value must contain at least 1`) 490 }) 491 Convey("non-terminal status", func() { 492 tc.Statuses = []apipb.Run_Status{ 493 apipb.Run_SUCCEEDED, 494 apipb.Run_PENDING, 495 } 496 validateProjectConfig(vctx, &cfg) 497 So(vctx.Finalize(), ShouldErrLike, `"PENDING" is not a terminal status`) 498 }) 499 Convey("duplicates", func() { 500 tc.Statuses = []apipb.Run_Status{ 501 apipb.Run_SUCCEEDED, 502 apipb.Run_SUCCEEDED, 503 } 504 validateProjectConfig(vctx, &cfg) 505 So(vctx.Finalize(), ShouldErrLike, `"SUCCEEDED" was specified already`) 506 }) 507 }) 508 }) 509 }) 510 }) 511 512 Convey("tryjob_experiments", func() { 513 exp := &cfgpb.ConfigGroup_TryjobExperiment{ 514 Name: "infra.experiment.foo", 515 Condition: &cfgpb.ConfigGroup_TryjobExperiment_Condition{ 516 OwnerGroupAllowlist: []string{"googlers"}, 517 }, 518 } 519 cfg.ConfigGroups[0].TryjobExperiments = []*cfgpb.ConfigGroup_TryjobExperiment{exp} 520 521 Convey("works", func() { 522 validateProjectConfig(vctx, &cfg) 523 So(vctx.Finalize(), ShouldBeNil) 524 }) 525 526 Convey("name", func() { 527 Convey("missing", func() { 528 exp.Name = "" 529 validateProjectConfig(vctx, &cfg) 530 So(vctx.Finalize(), ShouldErrLike, "Name: value length must be at least 1") 531 }) 532 533 Convey("duplicate", func() { 534 cfg.ConfigGroups[0].TryjobExperiments = []*cfgpb.ConfigGroup_TryjobExperiment{exp, exp} 535 validateProjectConfig(vctx, &cfg) 536 So(vctx.Finalize(), ShouldErrLike, `duplicate name "infra.experiment.foo"`) 537 }) 538 539 Convey("invalid name", func() { 540 exp.Name = "^&*()" 541 validateProjectConfig(vctx, &cfg) 542 So(vctx.Finalize(), ShouldErrLike, `"^&*()" does not match`) 543 }) 544 }) 545 546 Convey("Condition", func() { 547 Convey("owner_group_allowlist has empty string", func() { 548 exp.Condition.OwnerGroupAllowlist = []string{"infra.chromium.foo", ""} 549 validateProjectConfig(vctx, &cfg) 550 So(vctx.Finalize(), ShouldErrLike, "OwnerGroupAllowlist[1]: value length must be at least 1 ") 551 }) 552 }) 553 }) 554 555 Convey("Gerrit", func() { 556 g := cfg.ConfigGroups[0].Gerrit[0] 557 Convey("needs valid URL", func() { 558 g.Url = "" 559 validateProjectConfig(vctx, &cfg) 560 So(vctx.Finalize(), ShouldErrLike, "url is required") 561 562 g.Url = ":badscheme, bad URL" 563 vctx = &validation.Context{Context: ctx} 564 validateProjectConfig(vctx, &cfg) 565 So(vctx.Finalize(), ShouldErrLike, "failed to parse url") 566 }) 567 568 Convey("without fancy URL components", func() { 569 g.Url = "bad://ok/path-not-good?query=too#neither-is-fragment" 570 validateProjectConfig(vctx, &cfg) 571 err := vctx.Finalize() 572 So(err, ShouldErrLike, "path component not yet allowed in url") 573 So(err, ShouldErrLike, "and 5 other errors") 574 }) 575 576 Convey("current limitations", func() { 577 g.Url = "https://not.yet.allowed.com" 578 validateProjectConfig(vctx, &cfg) 579 So(vctx.Finalize(), ShouldErrLike, "only *.googlesource.com hosts supported for now") 580 581 vctx = &validation.Context{Context: ctx} 582 g.Url = "new-scheme://chromium-review.googlesource.com" 583 validateProjectConfig(vctx, &cfg) 584 So(vctx.Finalize(), ShouldErrLike, "only 'https' scheme supported for now") 585 }) 586 Convey("at least 1 project required", func() { 587 g.Projects = nil 588 validateProjectConfig(vctx, &cfg) 589 So(vctx.Finalize(), ShouldErrLike, "at least 1 project is required") 590 }) 591 Convey("no dup project blocks", func() { 592 g.Projects = append(g.Projects, g.Projects[0]) 593 validateProjectConfig(vctx, &cfg) 594 So(vctx.Finalize(), ShouldErrLike, "duplicate project in the same gerrit") 595 }) 596 }) 597 598 Convey("Gerrit Project", func() { 599 p := cfg.ConfigGroups[0].Gerrit[0].Projects[0] 600 Convey("project name required", func() { 601 p.Name = "" 602 validateProjectConfig(vctx, &cfg) 603 So(vctx.Finalize(), ShouldErrLike, "name is required") 604 }) 605 Convey("incorrect project names", func() { 606 p.Name = "a/prefix-not-allowed/so-is-.git-suffix/.git" 607 validateProjectConfig(vctx, &cfg) 608 So(vctx.Finalize(), ShouldNotBeNil) 609 610 vctx = &validation.Context{Context: ctx} 611 p.Name = "/prefix-not-allowed/so-is-/-suffix/" 612 validateProjectConfig(vctx, &cfg) 613 So(vctx.Finalize(), ShouldNotBeNil) 614 }) 615 Convey("bad regexp", func() { 616 p.RefRegexp = []string{"refs/heads/master", "*is-bad-regexp"} 617 validateProjectConfig(vctx, &cfg) 618 So(vctx.Finalize(), ShouldErrLike, "ref_regexp #2): error parsing regexp:") 619 }) 620 Convey("bad regexp_exclude", func() { 621 p.RefRegexpExclude = []string{"*is-bad-regexp"} 622 validateProjectConfig(vctx, &cfg) 623 So(vctx.Finalize(), ShouldErrLike, "ref_regexp_exclude #1): error parsing regexp:") 624 }) 625 Convey("duplicate regexp", func() { 626 p.RefRegexp = []string{"refs/heads/master", "refs/heads/master"} 627 validateProjectConfig(vctx, &cfg) 628 So(vctx.Finalize(), ShouldErrLike, "ref_regexp #2): duplicate regexp:") 629 }) 630 Convey("duplicate regexp include/exclude", func() { 631 p.RefRegexp = []string{"refs/heads/.+"} 632 p.RefRegexpExclude = []string{"refs/heads/.+"} 633 validateProjectConfig(vctx, &cfg) 634 So(vctx.Finalize(), ShouldErrLike, "ref_regexp_exclude #1): duplicate regexp:") 635 }) 636 }) 637 638 Convey("Verifiers", func() { 639 v := cfg.ConfigGroups[0].Verifiers 640 641 Convey("fake not allowed", func() { 642 v.Fake = &cfgpb.Verifiers_Fake{} 643 validateProjectConfig(vctx, &cfg) 644 So(vctx.Finalize(), ShouldErrLike, "fake verifier is not allowed") 645 }) 646 Convey("deprecator not allowed", func() { 647 v.Cqlinter = &cfgpb.Verifiers_CQLinter{} 648 validateProjectConfig(vctx, &cfg) 649 So(vctx.Finalize(), ShouldErrLike, "cqlinter verifier is not allowed") 650 }) 651 Convey("tree_status", func() { 652 v.TreeStatus = &cfgpb.Verifiers_TreeStatus{} 653 Convey("needs URL", func() { 654 validateProjectConfig(vctx, &cfg) 655 So(vctx.Finalize(), ShouldErrLike, "url is required") 656 }) 657 Convey("needs https URL", func() { 658 v.TreeStatus.Url = "http://example.com/test" 659 validateProjectConfig(vctx, &cfg) 660 So(vctx.Finalize(), ShouldErrLike, "url scheme must be 'https'") 661 }) 662 }) 663 Convey("gerrit_cq_ability", func() { 664 Convey("sane defaults", func() { 665 So(v.GerritCqAbility.AllowSubmitWithOpenDeps, ShouldBeFalse) 666 So(v.GerritCqAbility.AllowOwnerIfSubmittable, ShouldEqual, 667 cfgpb.Verifiers_GerritCQAbility_UNSET) 668 }) 669 Convey("is required", func() { 670 v.GerritCqAbility = nil 671 validateProjectConfig(vctx, &cfg) 672 So(vctx.Finalize(), ShouldErrLike, "gerrit_cq_ability verifier is required") 673 }) 674 Convey("needs committer_list", func() { 675 v.GerritCqAbility.CommitterList = nil 676 validateProjectConfig(vctx, &cfg) 677 So(vctx.Finalize(), ShouldErrLike, "committer_list is required") 678 }) 679 Convey("no empty committer_list", func() { 680 v.GerritCqAbility.CommitterList = []string{""} 681 validateProjectConfig(vctx, &cfg) 682 So(vctx.Finalize(), ShouldErrLike, "must not be empty") 683 }) 684 Convey("no empty dry_run_access_list", func() { 685 v.GerritCqAbility.DryRunAccessList = []string{""} 686 validateProjectConfig(vctx, &cfg) 687 So(vctx.Finalize(), ShouldErrLike, "must not be empty") 688 }) 689 Convey("may grant CL owners extra rights", func() { 690 v.GerritCqAbility.AllowOwnerIfSubmittable = cfgpb.Verifiers_GerritCQAbility_COMMIT 691 validateProjectConfig(vctx, &cfg) 692 So(vctx.Finalize(), ShouldBeNil) 693 }) 694 }) 695 }) 696 697 Convey("Tryjob", func() { 698 v := cfg.ConfigGroups[0].Verifiers.Tryjob 699 700 Convey("really bad retry config", func() { 701 v.RetryConfig.SingleQuota = -1 702 v.RetryConfig.GlobalQuota = -1 703 v.RetryConfig.FailureWeight = -1 704 v.RetryConfig.TransientFailureWeight = -1 705 v.RetryConfig.TimeoutWeight = -1 706 validateProjectConfig(vctx, &cfg) 707 So(vctx.Finalize(), ShouldErrLike, 708 "negative single_quota not allowed (-1 given) (and 4 other errors)") 709 }) 710 }) 711 712 Convey("UserLimits and UserLimitDefault", func() { 713 cg := cfg.ConfigGroups[0] 714 cg.UserLimits = []*cfgpb.UserLimit{ 715 { 716 Name: "user_limit", 717 Principals: []string{"user:foo@example.org"}, 718 Run: &cfgpb.UserLimit_Run{ 719 MaxActive: &cfgpb.UserLimit_Limit{ 720 Limit: &cfgpb.UserLimit_Limit_Value{Value: 123}, 721 }, 722 }, 723 Tryjob: &cfgpb.UserLimit_Tryjob{ 724 MaxActive: &cfgpb.UserLimit_Limit{ 725 Limit: &cfgpb.UserLimit_Limit_Unlimited{ 726 Unlimited: true, 727 }, 728 }, 729 }, 730 }, 731 { 732 Name: "group_limit", 733 Principals: []string{"group:bar"}, 734 Run: &cfgpb.UserLimit_Run{ 735 MaxActive: &cfgpb.UserLimit_Limit{ 736 Limit: &cfgpb.UserLimit_Limit_Unlimited{ 737 Unlimited: true, 738 }, 739 }, 740 }, 741 Tryjob: &cfgpb.UserLimit_Tryjob{ 742 MaxActive: &cfgpb.UserLimit_Limit{ 743 Limit: &cfgpb.UserLimit_Limit_Value{Value: 456}, 744 }, 745 }, 746 }, 747 } 748 cg.UserLimitDefault = &cfgpb.UserLimit{ 749 Name: "user_limit_default_limit", 750 Run: &cfgpb.UserLimit_Run{ 751 MaxActive: &cfgpb.UserLimit_Limit{ 752 Limit: &cfgpb.UserLimit_Limit_Unlimited{ 753 Unlimited: true, 754 }, 755 }, 756 }, 757 Tryjob: &cfgpb.UserLimit_Tryjob{ 758 MaxActive: &cfgpb.UserLimit_Limit{ 759 Limit: &cfgpb.UserLimit_Limit_Unlimited{ 760 Unlimited: true, 761 }, 762 }, 763 }, 764 } 765 validateProjectConfig(vctx, &cfg) 766 So(vctx.Finalize(), ShouldBeNil) 767 768 Convey("UserLimits doesn't allow nil", func() { 769 cg.UserLimits[1] = nil 770 validateProjectConfig(vctx, &cfg) 771 So(vctx.Finalize(), ShouldErrLike, "user_limits #2): cannot be nil") 772 }) 773 Convey("Names in UserLimits should be unique", func() { 774 cg.UserLimits[0].Name = cg.UserLimits[1].Name 775 validateProjectConfig(vctx, &cfg) 776 So(vctx.Finalize(), ShouldErrLike, "user_limits #2 / name): duplicate name") 777 }) 778 Convey("UserLimitDefault.Name should be unique", func() { 779 cg.UserLimitDefault.Name = cg.UserLimits[0].Name 780 validateProjectConfig(vctx, &cfg) 781 So(vctx.Finalize(), ShouldErrLike, "user_limit_default / name): duplicate name") 782 }) 783 Convey("Limit names must be valid", func() { 784 ok := func(n string) { 785 vctx := &validation.Context{Context: ctx} 786 cg.UserLimits[0].Name = n 787 validateProjectConfig(vctx, &cfg) 788 So(vctx.Finalize(), ShouldBeNil) 789 } 790 fail := func(n string) { 791 vctx := &validation.Context{Context: ctx} 792 cg.UserLimits[0].Name = n 793 validateProjectConfig(vctx, &cfg) 794 So(vctx.Finalize(), ShouldErrLike, "does not match") 795 } 796 ok("UserLimits") 797 ok("User-_@.+Limits") 798 ok("1User.Limits") 799 ok("User5.Limits-3") 800 fail("") 801 fail("user limit #1") 802 }) 803 Convey("UserLimits require principals", func() { 804 cg.UserLimits[0].Principals = nil 805 validateProjectConfig(vctx, &cfg) 806 So(vctx.Finalize(), ShouldErrLike, "user_limits #1 / principals): must have at least one") 807 }) 808 Convey("UserLimitDefault require no principals", func() { 809 cg.UserLimitDefault.Principals = []string{"group:committers"} 810 validateProjectConfig(vctx, &cfg) 811 So(vctx.Finalize(), ShouldErrLike, "user_limit_default / principals): must not have any") 812 }) 813 Convey("principals must be valid", func() { 814 ok := func(id string) { 815 vctx := &validation.Context{Context: ctx} 816 cg.UserLimits[0].Principals[0] = id 817 validateProjectConfig(vctx, &cfg) 818 So(vctx.Finalize(), ShouldBeNil) 819 } 820 fail := func(id, msg string) { 821 vctx := &validation.Context{Context: ctx} 822 cg.UserLimits[0].Principals[0] = id 823 validateProjectConfig(vctx, &cfg) 824 So(vctx.Finalize(), ShouldErrLike, msg) 825 } 826 ok("user:test@example.org") 827 ok("group:committers") 828 fail("user:", `"user:" doesn't look like a principal id`) 829 fail("user1", `"user1" doesn't look like a principal id`) 830 fail("group:", `"group:" doesn't look like a principal id`) 831 fail("bot:linux-123", `unknown principal type "bot"`) 832 fail("user:foo", `bad value "foo" for identity kind "user"`) 833 }) 834 Convey("limits are required", func() { 835 fail := func(msg string) { 836 vctx := &validation.Context{Context: ctx} 837 validateProjectConfig(vctx, &cfg) 838 So(vctx.Finalize(), ShouldErrLike, msg) 839 } 840 841 cg.UserLimits[0].Run = nil 842 fail("run): missing; set all limits with `unlimited` if there are no limits") 843 cg.UserLimits[0].Run = &cfgpb.UserLimit_Run{} 844 fail("run / max_active): missing; set `unlimited` if there is no limit") 845 }) 846 Convey("limits are > 0 or unlimited", func() { 847 ok := func(l *cfgpb.UserLimit_Limit, val int64, unlimited bool) { 848 vctx := &validation.Context{Context: ctx} 849 if unlimited { 850 l.Limit = &cfgpb.UserLimit_Limit_Unlimited{Unlimited: true} 851 } else { 852 l.Limit = &cfgpb.UserLimit_Limit_Value{Value: val} 853 } 854 validateProjectConfig(vctx, &cfg) 855 So(vctx.Finalize(), ShouldBeNil) 856 } 857 fail := func(l *cfgpb.UserLimit_Limit, val int64, unlimited bool, msg string) { 858 vctx := &validation.Context{Context: ctx} 859 l.Limit = &cfgpb.UserLimit_Limit_Unlimited{Unlimited: true} 860 if !unlimited { 861 l.Limit = &cfgpb.UserLimit_Limit_Value{Value: val} 862 } 863 validateProjectConfig(vctx, &cfg) 864 So(vctx.Finalize(), ShouldErrLike, msg) 865 } 866 867 // run limits 868 ulimit := cg.UserLimits[0] 869 fail(ulimit.Run.MaxActive, 0, false, "invalid limit 0;") 870 ok(ulimit.Run.MaxActive, 3, false) 871 ok(ulimit.Run.MaxActive, 0, true) 872 }) 873 }) 874 }) 875 } 876 877 func TestTryjobValidation(t *testing.T) { 878 t.Parallel() 879 880 Convey("Validate Tryjob Verifier Config", t, func() { 881 ct := cvtesting.Test{} 882 ctx, cancel := ct.SetUp(t) 883 defer cancel() 884 885 validate := func(textPB string, parentPB ...string) error { 886 vctx := &validation.Context{Context: ctx} 887 vd, err := makeProjectConfigValidator(vctx, "prj") 888 So(err, ShouldBeNil) 889 v := cfgpb.Verifiers{} 890 switch len(parentPB) { 891 case 0: 892 case 1: 893 if err := prototext.Unmarshal([]byte(parentPB[0]), &v); err != nil { 894 panic(err) 895 } 896 default: 897 panic("expected at most one parentPB") 898 } 899 cfg := cfgpb.Verifiers_Tryjob{} 900 switch err := prototext.Unmarshal([]byte(textPB), &cfg); { 901 case err != nil: 902 panic(err) 903 case v.Tryjob == nil: 904 v.Tryjob = &cfg 905 default: 906 proto.Merge(v.Tryjob, &cfg) 907 } 908 909 vd.validateTryjobVerifier(&v, standardModes) 910 return vctx.Finalize() 911 } 912 913 So(validate(``), ShouldBeNil) // allow empty builders. 914 915 So(mustError(validate(` 916 cancel_stale_tryjobs: YES 917 builders {name: "a/b/c"}`)), ShouldErrLike, "please remove") 918 So(mustError(validate(` 919 cancel_stale_tryjobs: NO 920 builders {name: "a/b/c"}`)), ShouldErrLike, "use per-builder `cancel_stale` instead") 921 922 Convey("builder name", func() { 923 So(validate(`builders {}`), ShouldErrLike, "name is required") 924 So(validate(`builders {name: ""}`), ShouldErrLike, "name is required") 925 So(validate(`builders {name: "a"}`), ShouldErrLike, 926 `name "a" doesn't match required format`) 927 So(validate(`builders {name: "a/b/c" equivalent_to {name: "z"}}`), ShouldErrLike, 928 `name "z" doesn't match required format`) 929 So(validate(`builders {name: "b/luci.b.try/c"}`), ShouldErrLike, 930 `name "b/luci.b.try/c" is highly likely malformed;`) 931 932 So(validate(` 933 builders {name: "a/b/c"} 934 builders {name: "a/b/c"} 935 `), ShouldErrLike, "duplicate") 936 937 So(validate(` 938 builders {name: "m/n/o"} 939 builders {name: "a/b/c" equivalent_to {name: "x/y/z"}} 940 `), ShouldBeNil) 941 942 So(validate(`builders {name: "123/b/c"}`), ShouldErrLike, 943 `first part of "123/b/c" is not a valid LUCI project name`) 944 }) 945 946 Convey("result_visibility", func() { 947 So(validate(` 948 builders {name: "a/b/c" result_visibility: COMMENT_LEVEL_UNSET} 949 `), ShouldBeNil) 950 So(validate(` 951 builders {name: "a/b/c" result_visibility: COMMENT_LEVEL_FULL} 952 `), ShouldBeNil) 953 So(validate(` 954 builders {name: "a/b/c" result_visibility: COMMENT_LEVEL_RESTRICTED} 955 `), ShouldBeNil) 956 }) 957 958 Convey("experiment", func() { 959 So(validate(`builders {name: "a/b/c" experiment_percentage: 1}`), ShouldBeNil) 960 So(validate(`builders {name: "a/b/c" experiment_percentage: -1}`), ShouldNotBeNil) 961 So(validate(`builders {name: "a/b/c" experiment_percentage: 101}`), ShouldNotBeNil) 962 }) 963 964 Convey("location_filters", func() { 965 So(validate(` 966 builders { 967 name: "a/b/c" 968 location_filters: { 969 gerrit_host_regexp: "" 970 gerrit_project_regexp: "" 971 path_regexp: ".*" 972 exclude: false 973 } 974 location_filters: { 975 gerrit_host_regexp: "chromium-review.googlesource.com" 976 gerrit_project_regexp: "chromium/src" 977 path_regexp: "README.md" 978 exclude: true 979 } 980 }`), ShouldBeNil) 981 982 So(validate(` 983 builders { 984 name: "a/b/c" 985 location_filters: { 986 gerrit_host_regexp: "bad \\c regexp" 987 } 988 }`), ShouldErrLike, "gerrit_host_regexp", "invalid regexp") 989 990 So(validate(` 991 builders { 992 name: "a/b/c" 993 location_filters: { 994 gerrit_host_regexp: "https://chromium-review.googlesource.com" 995 } 996 }`), ShouldErrLike, "gerrit_host_regexp", "scheme", "not needed") 997 998 So(validate(` 999 builders { 1000 name: "a/b/c" 1001 location_filters: { 1002 gerrit_project_regexp: "bad \\c regexp" 1003 } 1004 }`), ShouldErrLike, "gerrit_project_regexp", "invalid regexp") 1005 1006 So(validate(` 1007 builders { 1008 name: "a/b/c" 1009 location_filters: { 1010 path_regexp: "bad \\c regexp" 1011 } 1012 }`), ShouldErrLike, "path_regexp", "invalid regexp") 1013 }) 1014 1015 Convey("equivalent_to", func() { 1016 So(validate(` 1017 builders { 1018 name: "a/b/c" 1019 equivalent_to {name: "x/y/z" percentage: 10 owner_whitelist_group: "group"} 1020 }`), 1021 ShouldBeNil) 1022 1023 So(validate(` 1024 builders { 1025 name: "a/b/c" 1026 equivalent_to {name: "x/y/z" percentage: -1 owner_whitelist_group: "group"} 1027 }`), 1028 ShouldErrLike, "percentage must be between 0 and 100") 1029 So(validate(` 1030 builders { 1031 name: "a/b/c" 1032 equivalent_to {name: "a/b/c"} 1033 }`), 1034 ShouldErrLike, 1035 `equivalent_to.name must not refer to already defined "a/b/c" builder`) 1036 So(validate(` 1037 builders { 1038 name: "a/b/c" 1039 equivalent_to {name: "c/d/e"} 1040 } 1041 builders { 1042 name: "x/y/z" 1043 equivalent_to {name: "c/d/e"} 1044 }`), 1045 ShouldErrLike, 1046 `duplicate name "c/d/e"`) 1047 }) 1048 1049 Convey("owner_whitelist_group", func() { 1050 So(validate(`builders { name: "a/b/c" owner_whitelist_group: "ok" }`), ShouldBeNil) 1051 So(validate(` 1052 builders { 1053 name: "a/b/c" 1054 owner_whitelist_group: "ok" 1055 }`), ShouldBeNil) 1056 So(validate(` 1057 builders { 1058 name: "a/b/c" 1059 owner_whitelist_group: "ok" 1060 owner_whitelist_group: "" 1061 owner_whitelist_group: "also-ok" 1062 }`), ShouldErrLike, 1063 "must not be empty string") 1064 }) 1065 1066 Convey("mode_allowlist", func() { 1067 So(validate(`builders {name: "a/b/c" mode_allowlist: "DRY_RUN"}`), ShouldBeNil) 1068 So(validate(` 1069 builders { 1070 name: "a/b/c" 1071 mode_allowlist: "DRY_RUN" 1072 mode_allowlist: "FULL_RUN" 1073 }`), ShouldBeNil) 1074 1075 So(validate(` 1076 builders { 1077 name: "a/b/c" 1078 mode_allowlist: "DRY" 1079 mode_allowlist: "FULL_RUN" 1080 }`), ShouldErrLike, 1081 "must be one of") 1082 1083 So(validate(` 1084 builders { 1085 name: "a/b/c" 1086 mode_allowlist: "NEW_PATCHSET_RUN" 1087 }`), ShouldErrLike, 1088 "cannot be used unless a new_patchset_run_access_list is set") 1089 1090 Convey("contains ANALYZER_RUN", func() { 1091 So(validate(` 1092 builders { 1093 name: "a/b/c" 1094 location_filters: { 1095 path_regexp: ".*" 1096 } 1097 mode_allowlist: "ANALYZER_RUN" 1098 }`), ShouldErrLike, 1099 `analyzer location filter path pattern must match`) 1100 So(validate(` 1101 builders { 1102 name: "a/b/c" 1103 location_filters: { 1104 gerrit_project_regexp: "proj" 1105 path_regexp: ".+\\.go" 1106 } 1107 mode_allowlist: "ANALYZER_RUN" 1108 }`), ShouldErrLike, 1109 `analyzer location filter must include both host and project or neither`) 1110 So(validate(` 1111 builders { 1112 name: "a/b/c" 1113 location_filters: { 1114 path_regexp: ".+\\.py" 1115 exclude: True 1116 } 1117 mode_allowlist: "ANALYZER_RUN" 1118 }`), ShouldErrLike, 1119 `location_filters exclude filters are not combinable`) 1120 So(validate(` 1121 builders { 1122 name: "x/y/z" 1123 } 1124 builders { 1125 name: "a/b/c" 1126 mode_allowlist: "ANALYZER_RUN" 1127 }`), ShouldBeNil) 1128 So(validate(` 1129 builders { 1130 name: "x/y/z" 1131 } 1132 builders { 1133 name: "a/b/c" 1134 mode_allowlist: "ANALYZER_RUN" 1135 location_filters: { 1136 path_regexp: ".+\\.go" 1137 } 1138 }`), ShouldBeNil) 1139 So(validate(` 1140 builders { 1141 name: "x/y/z" 1142 } 1143 builders { 1144 name: "a/b/c" 1145 mode_allowlist: "ANALYZER_RUN" 1146 location_filters: { 1147 gerrit_host_regexp: "chromium-review.googlesource.com" 1148 gerrit_project_regexp: "proj" 1149 path_regexp: ".+\\.go" 1150 } 1151 }`), ShouldBeNil) 1152 }) 1153 }) 1154 1155 Convey("allowed combinations", func() { 1156 So(validate(` 1157 builders { 1158 name: "a/b/c" 1159 experiment_percentage: 1 1160 owner_whitelist_group: "owners" 1161 }`), 1162 ShouldBeNil) 1163 So(validate(` 1164 builders { 1165 name: "a/b/c" 1166 location_filters: { 1167 path_regexp: ".+\\.cpp" 1168 } 1169 } 1170 builders { 1171 name: "c/d/e" 1172 location_filters: { 1173 path_regexp: ".+\\.cpp" 1174 } 1175 } `), 1176 ShouldBeNil) 1177 So(validate(` 1178 builders {name: "pa/re/nt"} 1179 builders { 1180 name: "a/b/c" 1181 includable_only: true 1182 }`), 1183 ShouldBeNil) 1184 }) 1185 1186 Convey("disallowed combinations", func() { 1187 So(validate(` 1188 builders { 1189 name: "a/b/c" 1190 experiment_percentage: 1 1191 equivalent_to {name: "c/d/e"}}`), 1192 ShouldErrLike, 1193 "experiment_percentage is not combinable with equivalent_to") 1194 }) 1195 1196 Convey("includable_only", func() { 1197 So(validate(` 1198 builders { 1199 name: "a/b/c" 1200 experiment_percentage: 1 1201 includable_only: true 1202 }`), 1203 ShouldErrLike, 1204 "includable_only is not combinable with experiment_percentage") 1205 So(validate(` 1206 builders { 1207 name: "a/b/c" 1208 location_filters: { 1209 path_regexp: ".+\\.cpp" 1210 } 1211 includable_only: true 1212 }`), 1213 ShouldErrLike, 1214 "includable_only is not combinable with location_filters") 1215 So(validate(` 1216 builders { 1217 name: "a/b/c" 1218 mode_allowlist: "DRY_RUN" 1219 includable_only: true 1220 }`), 1221 ShouldErrLike, 1222 "includable_only is not combinable with mode_allowlist") 1223 1224 So(validate(`builders {name: "one/is/enough" includable_only: true}`), ShouldBeNil) 1225 }) 1226 }) 1227 }