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  }