go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/configs/validation/project.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 implements validation and common manipulation of CQ config
    16  // files.
    17  package validation
    18  
    19  import (
    20  	"fmt"
    21  	"net/url"
    22  	"regexp"
    23  	"strings"
    24  	"time"
    25  
    26  	"go.chromium.org/luci/auth/identity"
    27  	bbutil "go.chromium.org/luci/buildbucket/protoutil"
    28  	"go.chromium.org/luci/common/data/stringset"
    29  	"go.chromium.org/luci/common/errors"
    30  	luciconfig "go.chromium.org/luci/config"
    31  	"go.chromium.org/luci/config/validation"
    32  	"google.golang.org/protobuf/encoding/prototext"
    33  
    34  	cfgpb "go.chromium.org/luci/cv/api/config/v2"
    35  	apipb "go.chromium.org/luci/cv/api/v1"
    36  	"go.chromium.org/luci/cv/internal/configs/srvcfg"
    37  )
    38  
    39  const (
    40  	// CQStatusHostPublic is the public host of the CQ status app.
    41  	CQStatusHostPublic = "chromium-cq-status.appspot.com"
    42  	// CQStatusHostInternal is the internal host of the CQ status app.
    43  	CQStatusHostInternal = "internal-cq-status.appspot.com"
    44  
    45  	dummyProjectSkipListenerValidation = "dummy-project-skip-listener-validation-deabeef-abc"
    46  )
    47  
    48  var limitNameRe = regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z.\-@_+]{0,511}$`)
    49  
    50  // ValidateProject validates a project config for a given LUCI project.
    51  //
    52  // Validation result is returned via validation ctx, while error returned
    53  // directly implies internal errors.
    54  func ValidateProject(ctx *validation.Context, cfg *cfgpb.Config, project string) error {
    55  	vd, err := makeProjectConfigValidator(ctx, project)
    56  	if err != nil {
    57  		return errors.Annotate(err, "makeProjectConfigValidator").Err()
    58  	}
    59  	vd.validateProjectConfig(cfg)
    60  	return nil
    61  }
    62  
    63  // ValidateProjectConfig validates a given project config.
    64  //
    65  // This is essentially the same as ValidateProject, but skips checking
    66  // GerritSubscriptions in listener.Settings.
    67  func ValidateProjectConfig(ctx *validation.Context, cfg *cfgpb.Config) error {
    68  	return ValidateProject(ctx, cfg, dummyProjectSkipListenerValidation)
    69  }
    70  
    71  // validateProject validates a project-level CQ config.
    72  //
    73  // Validation result is returned via validation ctx, while error returned
    74  // directly implies only a bug in this code.
    75  func validateProject(ctx *validation.Context, configSet, path string, content []byte) error {
    76  	ctx.SetFile(path)
    77  	cfg := cfgpb.Config{}
    78  	if err := prototext.Unmarshal(content, &cfg); err != nil {
    79  		ctx.Error(err)
    80  		return nil
    81  	}
    82  	return ValidateProject(ctx, &cfg, luciconfig.Set(configSet).Project())
    83  }
    84  
    85  type projectConfigValidator struct {
    86  	ctx                            *validation.Context
    87  	subscribedGerritHosts          stringset.Set
    88  	projectEnabledInGerritListener bool
    89  }
    90  
    91  func makeProjectConfigValidator(ctx *validation.Context, project string) (*projectConfigValidator, error) {
    92  	switch project {
    93  	case "":
    94  		return nil, errors.Reason("empty project").Err()
    95  	case dummyProjectSkipListenerValidation:
    96  		return &projectConfigValidator{ctx: ctx}, nil
    97  	}
    98  
    99  	lCfg, err := srvcfg.GetListenerConfig(ctx.Context, nil)
   100  	if err != nil {
   101  		return nil, errors.Annotate(err, "GetListenerConfig").Err()
   102  	}
   103  	isEnabled, err := srvcfg.MakeListenerProjectChecker(lCfg)
   104  	if err != nil {
   105  		return nil, errors.Annotate(err, "MakeListenerProjectChecker").Err()
   106  	}
   107  	ret := &projectConfigValidator{
   108  		ctx:                            ctx,
   109  		subscribedGerritHosts:          stringset.New(len(lCfg.GetGerritSubscriptions())),
   110  		projectEnabledInGerritListener: isEnabled(project),
   111  	}
   112  	for _, sub := range lCfg.GetGerritSubscriptions() {
   113  		ret.subscribedGerritHosts.Add(sub.GetHost())
   114  	}
   115  	return ret, nil
   116  }
   117  
   118  func (vd *projectConfigValidator) validateProjectConfig(cfg *cfgpb.Config) {
   119  	if cfg.ProjectScopedAccount != cfgpb.Toggle_UNSET {
   120  		vd.ctx.Errorf("project_scoped_account for just CQ isn't supported. " +
   121  			"Use project-wide config for all LUCI services in luci-config/projects.cfg")
   122  	}
   123  	if cfg.DrainingStartTime != "" {
   124  		// TODO(crbug/1208569): re-enable or re-design this feature.
   125  		vd.ctx.Errorf("draining_start_time is temporarily not allowed, see https://crbug.com/1208569." +
   126  			"Reach out to LUCI team oncall if you need urgent help")
   127  	}
   128  	switch cfg.CqStatusHost {
   129  	case CQStatusHostInternal:
   130  	case CQStatusHostPublic:
   131  	case "":
   132  	default:
   133  		vd.ctx.Errorf("cq_status_host must be either empty or one of %q or %q", CQStatusHostPublic, CQStatusHostInternal)
   134  	}
   135  	if cfg.SubmitOptions != nil {
   136  		vd.ctx.Enter("submit_options")
   137  		if cfg.SubmitOptions.MaxBurst < 0 {
   138  			vd.ctx.Errorf("max_burst must be >= 0")
   139  		}
   140  		if d := cfg.SubmitOptions.BurstDelay; d != nil && d.AsDuration() < 0 {
   141  			vd.ctx.Errorf("burst_delay must be positive or 0")
   142  		}
   143  		vd.ctx.Exit()
   144  	}
   145  	if len(cfg.ConfigGroups) == 0 {
   146  		vd.ctx.Errorf("at least 1 config_group is required")
   147  		return
   148  	}
   149  
   150  	knownNames := make(stringset.Set, len(cfg.ConfigGroups))
   151  	fallbackGroupIdx := -1
   152  	for i, g := range cfg.ConfigGroups {
   153  		enter(vd.ctx, "config_group", i, g.Name)
   154  		vd.validateConfigGroup(g, knownNames)
   155  		switch {
   156  		case g.Fallback == cfgpb.Toggle_YES && fallbackGroupIdx == -1:
   157  			fallbackGroupIdx = i
   158  		case g.Fallback == cfgpb.Toggle_YES:
   159  			vd.ctx.Errorf("At most 1 config_group with fallback=YES allowed "+
   160  				"(already declared in config_group #%d", fallbackGroupIdx+1)
   161  		}
   162  		vd.ctx.Exit()
   163  	}
   164  }
   165  
   166  var (
   167  	configGroupNameRegexp = regexp.MustCompile("^[a-zA-Z][a-zA-Z0-9_-]{0,39}$")
   168  	modeNameRegexp        = regexp.MustCompile("^[a-zA-Z][a-zA-Z0-9_-]{0,39}$")
   169  	analyzerRun           = "ANALYZER_RUN"
   170  	standardModes         = stringset.NewFromSlice(analyzerRun, "DRY_RUN", "FULL_RUN", "NEW_PATCHSET_RUN")
   171  	analyzerPathReRegexp  = regexp.MustCompile(`^\.\+(\\\.[a-z]+)?$`)
   172  )
   173  
   174  func (vd *projectConfigValidator) validateConfigGroup(group *cfgpb.ConfigGroup, knownNames stringset.Set) {
   175  	switch {
   176  	case group.Name == "":
   177  		vd.ctx.Errorf("name is required")
   178  	case !configGroupNameRegexp.MatchString(group.Name):
   179  		vd.ctx.Errorf("name must match %q regexp but %q given", configGroupNameRegexp, group.Name)
   180  	case knownNames.Has(group.Name):
   181  		vd.ctx.Errorf("duplicate config_group name %q not allowed", group.Name)
   182  	default:
   183  		knownNames.Add(group.Name)
   184  	}
   185  
   186  	if len(group.Gerrit) == 0 {
   187  		vd.ctx.Errorf("at least 1 gerrit is required")
   188  	}
   189  	gerritURLs := stringset.Set{}
   190  	for i, g := range group.Gerrit {
   191  		enter(vd.ctx, "gerrit", i, g.Url)
   192  		vd.validateGerrit(g)
   193  		if g.Url != "" && !gerritURLs.Add(g.Url) {
   194  			vd.ctx.Errorf("duplicate gerrit url in the same config_group: %q", g.Url)
   195  		}
   196  		vd.ctx.Exit()
   197  	}
   198  
   199  	if group.CombineCls != nil {
   200  		vd.ctx.Enter("combine_cls")
   201  		switch d := group.CombineCls.StabilizationDelay; {
   202  		case d == nil:
   203  			vd.ctx.Errorf("stabilization_delay is required to enable cl_grouping")
   204  		case d.AsDuration() < 10*time.Second:
   205  			vd.ctx.Errorf("stabilization_delay must be at least 10 seconds")
   206  		}
   207  		if group.GetVerifiers().GetGerritCqAbility().GetAllowSubmitWithOpenDeps() {
   208  			vd.ctx.Errorf("combine_cls can not be used with gerrit_cq_ability.allow_submit_with_open_deps=true.")
   209  		}
   210  		vd.ctx.Exit()
   211  	}
   212  
   213  	additionalModes := stringset.New(len(group.AdditionalModes))
   214  	for i, m := range group.AdditionalModes {
   215  		vd.ctx.Enter("additional_modes #%d", (i + 1))
   216  		if err := m.ValidateAll(); err != nil {
   217  			vd.ctx.Errorf("%s", err)
   218  		}
   219  		vd.ctx.Enter("name")
   220  		if !additionalModes.Add(m.Name) {
   221  			vd.ctx.Errorf("%q is already in use", m.Name)
   222  		}
   223  		vd.ctx.Exit()
   224  		vd.ctx.Exit()
   225  	}
   226  
   227  	paNames := stringset.New(len(group.PostActions))
   228  	for i, pa := range group.PostActions {
   229  		vd.ctx.Enter("post_actions #%d", (i + 1))
   230  		if err := pa.ValidateAll(); err != nil {
   231  			vd.ctx.Errorf("%s", err)
   232  		}
   233  
   234  		vd.ctx.Enter("name")
   235  		if !paNames.Add(pa.GetName()) {
   236  			vd.ctx.Errorf("name '%q' is already in use", pa.GetName())
   237  		}
   238  		vd.ctx.Exit() // name
   239  		for i, tc := range pa.GetConditions() {
   240  			vd.ctx.Enter("conditions #%d", (i + 1))
   241  			switch m := tc.GetMode(); {
   242  			case m == "DRY_RUN":
   243  			case m == "FULL_RUN":
   244  			case m == "NEW_PATCHSET_RUN":
   245  			case additionalModes.Has(m):
   246  			default:
   247  				vd.ctx.Enter("mode")
   248  				vd.ctx.Errorf("invalid mode %q", m)
   249  				vd.ctx.Exit()
   250  			}
   251  
   252  			// pgv's enum.in accepts the numeric representation of enum values,
   253  			// which produces non-so-readable error messages.
   254  			// check the statuses here to produce better error messages.
   255  			sts := stringset.New(len(tc.GetStatuses()))
   256  			for i, st := range tc.GetStatuses() {
   257  				vd.ctx.Enter("statuses #%d", (i + 1))
   258  				switch st {
   259  				case apipb.Run_SUCCEEDED, apipb.Run_FAILED, apipb.Run_CANCELLED:
   260  					if !sts.Add(st.String()) {
   261  						vd.ctx.Errorf("%q was specified already", st)
   262  					}
   263  				default:
   264  					vd.ctx.Errorf("%q is not a terminal status", st)
   265  				}
   266  				vd.ctx.Exit() // statuses #i
   267  			}
   268  			vd.ctx.Exit() // conditions #i
   269  		}
   270  		vd.ctx.Enter("action")
   271  		switch act := pa.GetAction().(type) {
   272  		case nil:
   273  		case *cfgpb.ConfigGroup_PostAction_VoteGerritLabels_:
   274  			vd.validateVoteGerritLabels(act.VoteGerritLabels)
   275  		default:
   276  			// This must be a bug in this code.
   277  			panic(errors.Reason("unknown action; please fix"))
   278  		}
   279  		vd.ctx.Exit() // action
   280  		vd.ctx.Exit() // post_actions #i
   281  	}
   282  
   283  	teNames := stringset.New(len(group.GetTryjobExperiments()))
   284  	for i, te := range group.GetTryjobExperiments() {
   285  		vd.ctx.Enter("tryjob_experiments #%d", (i + 1))
   286  		if err := te.ValidateAll(); err != nil {
   287  			vd.ctx.Errorf("%s", err)
   288  		}
   289  
   290  		vd.ctx.Enter("name")
   291  		name := te.GetName()
   292  		if !teNames.Add(te.GetName()) {
   293  			vd.ctx.Errorf("duplicate name %q", name)
   294  		}
   295  		if !bbutil.ExperimentNameRE.MatchString(name) {
   296  			vd.ctx.Errorf("%q does not match %q", name, bbutil.ExperimentNameRE)
   297  		}
   298  		vd.ctx.Exit() // name
   299  		vd.ctx.Exit() // tryjob_experiments #i
   300  	}
   301  
   302  	if group.Verifiers == nil {
   303  		vd.ctx.Errorf("verifiers are required")
   304  	} else {
   305  		vd.ctx.Enter("verifiers")
   306  		vd.validateVerifiers(group.Verifiers, additionalModes.Union(standardModes))
   307  		vd.ctx.Exit()
   308  	}
   309  	vd.validateUserLimits(group.GetUserLimits(), group.GetUserLimitDefault())
   310  }
   311  
   312  func (vd *projectConfigValidator) validateVoteGerritLabels(work *cfgpb.ConfigGroup_PostAction_VoteGerritLabels) {
   313  	// perform extra validations that are not checked by PGV.
   314  	labels := stringset.New(len(work.Votes))
   315  	for i, vote := range work.Votes {
   316  		if !labels.Add(vote.Name) {
   317  			vd.ctx.Enter("votes #%d", (i + 1))
   318  			vd.ctx.Errorf("label %q already specified", vote.Name)
   319  			vd.ctx.Exit()
   320  		}
   321  	}
   322  }
   323  
   324  func (vd *projectConfigValidator) validateGerrit(g *cfgpb.ConfigGroup_Gerrit) {
   325  	vd.validateGerritURL(g.Url)
   326  	if len(g.Projects) == 0 {
   327  		vd.ctx.Errorf("at least 1 project is required")
   328  	}
   329  	nameToIndex := make(map[string]int, len(g.Projects))
   330  	for i, p := range g.Projects {
   331  		enter(vd.ctx, "projects", i, p.Name)
   332  		vd.validateGerritProject(p)
   333  		if p.Name != "" {
   334  			if _, dup := nameToIndex[p.Name]; !dup {
   335  				nameToIndex[p.Name] = i
   336  			} else {
   337  				vd.ctx.Errorf("duplicate project in the same gerrit: %q", p.Name)
   338  			}
   339  		}
   340  		// TODO(crbug.com/1358208): check if listener-settings.cfg has
   341  		// a subscription for all the Gerrit hosts, if the LUCI project is
   342  		// enabled in the pubsub listener.
   343  		vd.ctx.Exit()
   344  	}
   345  }
   346  
   347  func (vd *projectConfigValidator) validateGerritURL(gURL string) {
   348  	if gURL == "" {
   349  		vd.ctx.Errorf("url is required")
   350  		return
   351  	}
   352  	u, err := url.Parse(gURL)
   353  	if err != nil {
   354  		vd.ctx.Errorf("failed to parse url %q: %s", gURL, err)
   355  		return
   356  	}
   357  	if u.Path != "" {
   358  		vd.ctx.Errorf("path component not yet allowed in url (%q specified)", u.Path)
   359  	}
   360  	if u.RawQuery != "" {
   361  		vd.ctx.Errorf("query component not allowed in url (%q specified)", u.RawQuery)
   362  	}
   363  	if u.Fragment != "" {
   364  		vd.ctx.Errorf("fragment component not allowed in url (%q specified)", u.Fragment)
   365  	}
   366  	if u.Scheme != "https" {
   367  		vd.ctx.Errorf("only 'https' scheme supported for now (%q specified)", u.Scheme)
   368  	}
   369  	if !strings.HasSuffix(u.Host, ".googlesource.com") {
   370  		// TODO(tandrii): relax this.
   371  		vd.ctx.Errorf("only *.googlesource.com hosts supported for now (%q specified)", u.Host)
   372  	}
   373  	if vd.projectEnabledInGerritListener && !vd.subscribedGerritHosts.Has(u.Host) {
   374  		vd.ctx.Errorf("Gerrit pub/sub for %q is not configured; please visit go/luci/cv/gerrit-pubsub#validation-error", u.Host)
   375  	}
   376  }
   377  
   378  func (vd *projectConfigValidator) validateGerritProject(gp *cfgpb.ConfigGroup_Gerrit_Project) {
   379  	if gp.Name == "" {
   380  		vd.ctx.Errorf("name is required")
   381  	} else {
   382  		if strings.HasPrefix(gp.Name, "/") || strings.HasPrefix(gp.Name, "a/") {
   383  			vd.ctx.Errorf("name must not start with '/' or 'a/'")
   384  		}
   385  		if strings.HasSuffix(gp.Name, "/") || strings.HasSuffix(gp.Name, ".git") {
   386  			vd.ctx.Errorf("name must not end with '.git' or '/'")
   387  		}
   388  	}
   389  
   390  	regexps := stringset.Set{}
   391  	for i, r := range gp.RefRegexp {
   392  		vd.ctx.Enter("ref_regexp #%d", i+1)
   393  		if _, err := regexpCompileCached(r); err != nil {
   394  			vd.ctx.Error(err)
   395  		}
   396  		if !regexps.Add(r) {
   397  			vd.ctx.Errorf("duplicate regexp: %q", r)
   398  		}
   399  		vd.ctx.Exit()
   400  	}
   401  	for i, r := range gp.RefRegexpExclude {
   402  		vd.ctx.Enter("ref_regexp_exclude #%d", i+1)
   403  		if _, err := regexpCompileCached(r); err != nil {
   404  			vd.ctx.Error(err)
   405  		}
   406  		if !regexps.Add(r) {
   407  			// There is no point excluding exact same regexp as including.
   408  			vd.ctx.Errorf("duplicate regexp: %q", r)
   409  		}
   410  		vd.ctx.Exit()
   411  	}
   412  }
   413  
   414  func (vd *projectConfigValidator) validateVerifiers(v *cfgpb.Verifiers, supportedModes stringset.Set) {
   415  	if v.Cqlinter != nil {
   416  		vd.ctx.Errorf("cqlinter verifier is not allowed (internal use only)")
   417  	}
   418  	if v.Fake != nil {
   419  		vd.ctx.Errorf("fake verifier is not allowed (internal use only)")
   420  	}
   421  	if v.TreeStatus != nil {
   422  		vd.ctx.Enter("tree_status")
   423  		if v.TreeStatus.Url == "" {
   424  			vd.ctx.Errorf("url is required")
   425  		} else {
   426  			switch u, err := url.Parse(v.TreeStatus.Url); {
   427  			case err != nil:
   428  				vd.ctx.Errorf("failed to parse url %q: %s", v.TreeStatus.Url, err)
   429  			case u.Scheme != "https":
   430  				vd.ctx.Errorf("url scheme must be 'https'")
   431  			}
   432  		}
   433  		vd.ctx.Exit()
   434  	}
   435  	if v.GerritCqAbility == nil {
   436  		vd.ctx.Errorf("gerrit_cq_ability verifier is required")
   437  	} else {
   438  		vd.ctx.Enter("gerrit_cq_ability")
   439  		if len(v.GerritCqAbility.CommitterList) == 0 {
   440  			vd.ctx.Errorf("committer_list is required")
   441  		} else {
   442  			for i, l := range v.GerritCqAbility.CommitterList {
   443  				if l == "" {
   444  					vd.ctx.Enter("committer_list #%d", i+1)
   445  					vd.ctx.Errorf("must not be empty string")
   446  					vd.ctx.Exit()
   447  				}
   448  			}
   449  		}
   450  		for i, l := range v.GerritCqAbility.DryRunAccessList {
   451  			if l == "" {
   452  				vd.ctx.Enter("dry_run_access_list #%d", i+1)
   453  				vd.ctx.Errorf("must not be empty string")
   454  				vd.ctx.Exit()
   455  			}
   456  		}
   457  		for i, l := range v.GerritCqAbility.NewPatchsetRunAccessList {
   458  			if l == "" {
   459  				vd.ctx.Enter("new_patchset_run_access_list #%d", i+1)
   460  				vd.ctx.Errorf("must not be empty string")
   461  				vd.ctx.Exit()
   462  			}
   463  		}
   464  		vd.ctx.Exit()
   465  	}
   466  	if v.Tryjob != nil && len(v.Tryjob.Builders) > 0 {
   467  		vd.ctx.Enter("tryjob")
   468  		vd.validateTryjobVerifier(v, supportedModes)
   469  		vd.ctx.Exit()
   470  	}
   471  }
   472  
   473  // validateTryjobVerifier validates the tryjob verifier in a config.
   474  //
   475  // The tryjob verifier generally includes multiple builders.
   476  func (vd *projectConfigValidator) validateTryjobVerifier(v *cfgpb.Verifiers, supportedModes stringset.Set) {
   477  	vt := v.Tryjob
   478  	if vt.RetryConfig != nil {
   479  		vd.ctx.Enter("retry_config")
   480  		vd.validateTryjobRetry(vt.RetryConfig)
   481  		vd.ctx.Exit()
   482  	}
   483  
   484  	switch vt.CancelStaleTryjobs {
   485  	case cfgpb.Toggle_YES:
   486  		vd.ctx.Errorf("`cancel_stale_tryjobs: YES` matches default CQ behavior now; please remove")
   487  	case cfgpb.Toggle_NO:
   488  		vd.ctx.Errorf("`cancel_stale_tryjobs: NO` is no longer supported, use per-builder `cancel_stale` instead")
   489  	case cfgpb.Toggle_UNSET:
   490  		// OK
   491  	}
   492  
   493  	visitBuilders := func(cb func(b *cfgpb.Verifiers_Tryjob_Builder)) {
   494  		for i, b := range vt.Builders {
   495  			enter(vd.ctx, "builders", i, b.Name)
   496  			cb(b)
   497  			vd.ctx.Exit()
   498  		}
   499  	}
   500  
   501  	// Here we iterate through all builders here and accumulate a set that
   502  	// contains all builder names. Names are validated when added to the set.
   503  	// This includes checking for duplicates. This is done before the main
   504  	// verification pass below so that all builder names are available below.
   505  	builderNames := stringset.Set{}
   506  	equiBuilderNames := stringset.Set{}
   507  	visitBuilders(func(b *cfgpb.Verifiers_Tryjob_Builder) {
   508  		vd.validateBuilderName(b.Name, builderNames)
   509  	})
   510  
   511  	visitBuilders(func(b *cfgpb.Verifiers_Tryjob_Builder) {
   512  		if b.EquivalentTo != nil {
   513  			vd.validateEquivalentBuilder(b.EquivalentTo, equiBuilderNames)
   514  			if b.ExperimentPercentage != 0 {
   515  				vd.ctx.Errorf("experiment_percentage is not combinable with equivalent_to")
   516  			}
   517  			if b.EquivalentTo.Name != "" && builderNames.Has(b.EquivalentTo.Name) {
   518  				vd.ctx.Errorf("equivalent_to.name must not refer to already defined %q builder", b.EquivalentTo.Name)
   519  			}
   520  		}
   521  		if b.ExperimentPercentage != 0 {
   522  			if b.ExperimentPercentage < 0.0 || b.ExperimentPercentage > 100.0 {
   523  				vd.ctx.Errorf("experiment_percentage must between 0 and 100 (%f given)", b.ExperimentPercentage)
   524  			}
   525  			if b.IncludableOnly {
   526  				vd.ctx.Errorf("includable_only is not combinable with experiment_percentage")
   527  			}
   528  		}
   529  		if len(b.LocationFilters) > 0 {
   530  			vd.validateLocationFilters(b.GetLocationFilters())
   531  			if b.IncludableOnly {
   532  				vd.ctx.Errorf("includable_only is not combinable with location_filters")
   533  			}
   534  		}
   535  
   536  		if len(b.OwnerWhitelistGroup) > 0 {
   537  			for i, g := range b.OwnerWhitelistGroup {
   538  				if g == "" {
   539  					vd.ctx.Enter("owner_whitelist_group #%d", i+1)
   540  					vd.ctx.Errorf("must not be empty string")
   541  					vd.ctx.Exit()
   542  				}
   543  			}
   544  		}
   545  
   546  		var isAnalyzer bool
   547  		if len(b.ModeAllowlist) > 0 {
   548  			for i, m := range b.ModeAllowlist {
   549  				switch {
   550  				case !supportedModes.Has(m):
   551  					vd.ctx.Enter("mode_allowlist #%d", i+1)
   552  					vd.ctx.Errorf("must be one of %s", supportedModes.ToSortedSlice())
   553  					vd.ctx.Exit()
   554  				case m == "NEW_PATCHSET_RUN" && len(v.GetGerritCqAbility().GetNewPatchsetRunAccessList()) == 0:
   555  					vd.ctx.Enter("mode_allowlist #%d", i+1)
   556  					vd.ctx.Errorf("mode NEW_PATCHSET_RUN cannot be used unless a new_patchset_run_access_list is set")
   557  					vd.ctx.Exit()
   558  				case m == analyzerRun:
   559  					isAnalyzer = true
   560  				}
   561  			}
   562  			if isAnalyzer {
   563  				// TODO(crbug/1202952): Remove the following check after Tricium is folded into CV.
   564  				for i, f := range b.LocationFilters {
   565  					vd.ctx.Enter("location_filters #%d", i+1)
   566  					if !analyzerPathReRegexp.MatchString(f.PathRegexp) {
   567  						vd.ctx.Errorf(`analyzer location filter path pattern must match %q.`, analyzerPathReRegexp)
   568  					}
   569  					if (!matchAll(f.GerritProjectRegexp) && matchAll(f.GerritHostRegexp)) ||
   570  						(matchAll(f.GerritProjectRegexp) && !matchAll(f.GerritHostRegexp)) {
   571  						vd.ctx.Errorf(`analyzer location filter must include both host and project or neither.`)
   572  					}
   573  					if f.Exclude {
   574  						vd.ctx.Errorf(`location_filters exclude filters are not combinable with analyzer mode`)
   575  					}
   576  					vd.ctx.Exit()
   577  				}
   578  			}
   579  			// TODO(crbug/1191855): See if CV should loosen the following restrictions.
   580  			if b.IncludableOnly {
   581  				vd.ctx.Errorf("includable_only is not combinable with mode_allowlist")
   582  			}
   583  		}
   584  	})
   585  }
   586  
   587  func matchAll(re string) bool {
   588  	return re == "" || re == ".*" || re == ".+"
   589  }
   590  
   591  // Validate a builder name. If knownNames is non-nil, then add it to the set.
   592  func (vd *projectConfigValidator) validateBuilderName(name string, knownNames stringset.Set) {
   593  	if name == "" {
   594  		vd.ctx.Errorf("name is required")
   595  		return
   596  	}
   597  	if knownNames != nil {
   598  		if !knownNames.Add(name) {
   599  			vd.ctx.Errorf("duplicate name %q", name)
   600  		}
   601  	}
   602  	parts := strings.Split(name, "/")
   603  	if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
   604  		vd.ctx.Errorf("name %q doesn't match required format project/short-bucket-name/builder, e.g. 'v8/try/linux'", name)
   605  	}
   606  	for _, part := range parts {
   607  		subs := strings.Split(part, ".")
   608  		if len(subs) >= 3 && subs[0] == "luci" {
   609  			// Technically, this is allowed. However, practically, this is
   610  			// extremely likely to be misunderstanding of project or bucket is.
   611  			vd.ctx.Errorf("name %q is highly likely malformed; it should be project/short-bucket-name/builder, e.g. 'v8/try/linux'", name)
   612  			return
   613  		}
   614  	}
   615  	if err := luciconfig.ValidateProjectName(parts[0]); err != nil {
   616  		vd.ctx.Errorf("first part of %q is not a valid LUCI project name", name)
   617  	}
   618  }
   619  
   620  func (vd *projectConfigValidator) validateEquivalentBuilder(b *cfgpb.Verifiers_Tryjob_EquivalentBuilder, equiNames stringset.Set) {
   621  	vd.ctx.Enter("equivalent_to")
   622  	defer vd.ctx.Exit()
   623  	vd.validateBuilderName(b.Name, equiNames)
   624  	if b.Percentage < 0 || b.Percentage > 100 {
   625  		vd.ctx.Errorf("percentage must be between 0 and 100 (%f given)", b.Percentage)
   626  	}
   627  }
   628  
   629  type regexpExtraCheck func(ctx *validation.Context, field string, r *regexp.Regexp, value string)
   630  
   631  func validateRegexp(ctx *validation.Context, field string, values []string, extra ...regexpExtraCheck) {
   632  	valid := stringset.New(len(values))
   633  	for i, v := range values {
   634  		if v == "" {
   635  			ctx.Errorf("%s #%d: must not be empty", field, i+1)
   636  			continue
   637  		}
   638  		if !valid.Add(v) {
   639  			ctx.Errorf("duplicate %s: %q", field, v)
   640  			continue
   641  		}
   642  		r, err := regexpCompileCached(v)
   643  		if err != nil {
   644  			ctx.Errorf("%s %q: %s", field, v, err)
   645  			continue
   646  		}
   647  		for _, f := range extra {
   648  			f(ctx, field, r, v)
   649  		}
   650  	}
   651  }
   652  
   653  // validateLocationFilters validates that all location filters have valid
   654  // regular expressions.
   655  func (vd *projectConfigValidator) validateLocationFilters(filters []*cfgpb.Verifiers_Tryjob_Builder_LocationFilter) {
   656  	for i, filter := range filters {
   657  		vd.ctx.Enter("location_filters #%d", i+1)
   658  		if filter == nil {
   659  			vd.ctx.Errorf("must not be nil")
   660  			continue
   661  		}
   662  
   663  		if hostRE := filter.GetGerritHostRegexp(); hostRE != "" {
   664  			vd.ctx.Enter("gerrit_host_regexp")
   665  			if strings.HasPrefix(hostRE, "http") {
   666  				vd.ctx.Errorf("scheme (http:// or https://) is not needed")
   667  			}
   668  			if _, err := regexpCompileCached(hostRE); err != nil {
   669  				vd.ctx.Errorf("invalid regexp: %q; error: %s", hostRE, err)
   670  			}
   671  			vd.ctx.Exit()
   672  		}
   673  
   674  		if repoRE := filter.GetGerritProjectRegexp(); repoRE != "" {
   675  			vd.ctx.Enter("gerrit_project_regexp")
   676  			if _, err := regexpCompileCached(repoRE); err != nil {
   677  				vd.ctx.Errorf("invalid regexp: %q; error: %s", repoRE, err)
   678  			}
   679  			vd.ctx.Exit()
   680  		}
   681  
   682  		if pathRE := filter.GetPathRegexp(); pathRE != "" {
   683  			vd.ctx.Enter("path_regexp")
   684  			if _, err := regexpCompileCached(pathRE); err != nil {
   685  				vd.ctx.Errorf("invalid regexp: %q; error: %s", pathRE, err)
   686  			}
   687  			vd.ctx.Exit()
   688  		}
   689  		vd.ctx.Exit()
   690  	}
   691  }
   692  
   693  func (vd *projectConfigValidator) validateTryjobRetry(r *cfgpb.Verifiers_Tryjob_RetryConfig) {
   694  	if r.SingleQuota < 0 {
   695  		vd.ctx.Errorf("negative single_quota not allowed (%d given)", r.SingleQuota)
   696  	}
   697  	if r.GlobalQuota < 0 {
   698  		vd.ctx.Errorf("negative global_quota not allowed (%d given)", r.GlobalQuota)
   699  	}
   700  	if r.FailureWeight < 0 {
   701  		vd.ctx.Errorf("negative failure_weight not allowed (%d given)", r.FailureWeight)
   702  	}
   703  	if r.TransientFailureWeight < 0 {
   704  		vd.ctx.Errorf("negative transitive_failure_weight not allowed (%d given)", r.TransientFailureWeight)
   705  	}
   706  	if r.TimeoutWeight < 0 {
   707  		vd.ctx.Errorf("negative timeout_weight not allowed (%d given)", r.TimeoutWeight)
   708  	}
   709  }
   710  
   711  func (vd *projectConfigValidator) validateUserLimits(limits []*cfgpb.UserLimit, def *cfgpb.UserLimit) {
   712  	names := stringset.New(len(limits))
   713  	for i, l := range limits {
   714  		vd.ctx.Enter("user_limits #%d", i+1)
   715  		if l == nil {
   716  			vd.ctx.Errorf("cannot be nil")
   717  		} else {
   718  			vd.validateUserLimit(l, names, true)
   719  		}
   720  		vd.ctx.Exit()
   721  	}
   722  
   723  	if def != nil {
   724  		vd.ctx.Enter("user_limit_default")
   725  		vd.validateUserLimit(def, names, false)
   726  		vd.ctx.Exit()
   727  	}
   728  }
   729  
   730  // validateUserLimit validates one cfgpb.UserLimit.
   731  func (vd *projectConfigValidator) validateUserLimit(limit *cfgpb.UserLimit, namesSeen stringset.Set, principalsRequired bool) {
   732  	vd.ctx.Enter("name")
   733  	if !namesSeen.Add(limit.GetName()) {
   734  		vd.ctx.Errorf("duplicate name %q", limit.GetName())
   735  	}
   736  	if !limitNameRe.MatchString(limit.GetName()) {
   737  		vd.ctx.Errorf("%q does not match %q", limit.GetName(), limitNameRe)
   738  	}
   739  	vd.ctx.Exit()
   740  
   741  	vd.ctx.Enter("principals")
   742  	switch {
   743  	case principalsRequired && len(limit.GetPrincipals()) == 0:
   744  		vd.ctx.Errorf("must have at least one principal")
   745  	case !principalsRequired && len(limit.GetPrincipals()) > 0:
   746  		vd.ctx.Errorf("must not have any principals (%d principal(s) given)", len(limit.GetPrincipals()))
   747  	}
   748  	vd.ctx.Exit()
   749  
   750  	for i, id := range limit.GetPrincipals() {
   751  		vd.ctx.Enter("principals #%d", i+1)
   752  		if err := vd.validatePrincipalID(id); err != nil {
   753  			vd.ctx.Errorf("%s", err)
   754  		}
   755  		vd.ctx.Exit()
   756  	}
   757  
   758  	vd.ctx.Enter("run")
   759  	switch r := limit.GetRun(); {
   760  	case r == nil:
   761  		vd.ctx.Errorf("missing; set all limits with `unlimited` if there are no limits")
   762  	default:
   763  		vd.ctx.Enter("max_active")
   764  		if err := vd.validateLimit(r.GetMaxActive()); err != nil {
   765  			vd.ctx.Errorf("%s", err)
   766  		}
   767  		vd.ctx.Exit()
   768  	}
   769  	vd.ctx.Exit()
   770  }
   771  
   772  func (vd *projectConfigValidator) validatePrincipalID(id string) error {
   773  	chunks := strings.Split(id, ":")
   774  	if len(chunks) != 2 || chunks[0] == "" || chunks[1] == "" {
   775  		return fmt.Errorf("%q doesn't look like a principal id (<type>:<id>)", id)
   776  	}
   777  
   778  	switch chunks[0] {
   779  	case "group":
   780  		return nil // Any non-empty group name is OK
   781  	case "user":
   782  		// Should be a valid identity.
   783  		_, err := identity.MakeIdentity(id)
   784  		return err
   785  	}
   786  	return fmt.Errorf("unknown principal type %q", chunks[0])
   787  }
   788  
   789  func (vd *projectConfigValidator) validateLimit(l *cfgpb.UserLimit_Limit) error {
   790  	switch l.GetLimit().(type) {
   791  	case *cfgpb.UserLimit_Limit_Unlimited:
   792  	case *cfgpb.UserLimit_Limit_Value:
   793  		if val := l.GetValue(); val < 1 {
   794  			return errors.Reason("invalid limit %d; must be > 0", val).Err()
   795  		}
   796  	case nil:
   797  		return errors.Reason("missing; set `unlimited` if there is no limit").Err()
   798  	default:
   799  		return errors.Reason("unknown limit type %T", l.GetLimit()).Err()
   800  	}
   801  	return nil
   802  }