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 }