blob: 2fb42e8748999742c3d6f52280a4ea2a55b255b9 [file] [log] [blame]
// Copyright 2018 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package config implements validation and common manipulation of CQ config
// files.
package config
import (
"net/url"
"regexp"
"strings"
"github.com/golang/protobuf/proto"
"github.com/golang/protobuf/ptypes"
"go.chromium.org/luci/common/data/stringset"
lc "go.chromium.org/luci/config"
"go.chromium.org/luci/config/validation"
v2 "go.chromium.org/luci/cv/api/config/v2"
migrationpb "go.chromium.org/luci/cv/api/migration"
)
// Config validation rules go here.
func init() {
addRules(&validation.Rules)
}
func addRules(r *validation.RuleSet) {
r.Add("regex:projects/[^/]+", "${appid}.cfg", validateProject)
r.Add("services/${appid}", "migration-settings.cfg", validateMigrationSettings)
}
// validateProject validates a project-level CQ config.
//
// Validation result is returned via validation ctx, while error returned
// directly implies only a bug in this code.
func validateProject(ctx *validation.Context, configSet, path string, content []byte) error {
ctx.SetFile(path)
cfg := v2.Config{}
if err := proto.UnmarshalText(string(content), &cfg); err != nil {
ctx.Error(err)
} else {
validateProjectConfig(ctx, &cfg)
}
return nil
}
func validateProjectConfig(ctx *validation.Context, cfg *v2.Config) {
if cfg.ProjectScopedAccount != v2.Toggle_UNSET {
ctx.Errorf("project_scoped_account for just CQ isn't supported. " +
"Use project-wide config for all LUCI services in luci-config/projects.cfg")
}
if cfg.DrainingStartTime != "" {
// TODO(crbug/1208569): re-enable or re-design this feature.
ctx.Errorf("draining_start_time is temporarily not allowed, see https://crbug.com/1208569." +
"Reach out to LUCI team oncall if you need urgent help")
}
if cfg.CqStatusHost != "" {
switch u, err := url.Parse("https://" + cfg.CqStatusHost); {
case err != nil:
ctx.Errorf("failed to parse cq_status_host %q: %s", cfg.CqStatusHost, err)
case u.Host != cfg.CqStatusHost:
ctx.Errorf("cq_status_host %q should be just a host %q", cfg.CqStatusHost, u.Host)
}
}
if cfg.SubmitOptions != nil {
ctx.Enter("submit_options")
if cfg.SubmitOptions.MaxBurst < 0 {
ctx.Errorf("max_burst must be >= 0")
}
if cfg.SubmitOptions.BurstDelay != nil {
switch d, err := ptypes.Duration(cfg.SubmitOptions.BurstDelay); {
case err != nil:
ctx.Errorf("invalid burst_delay: %s", err)
case d.Seconds() < 0.0:
ctx.Errorf("burst_delay must be positive or 0")
}
}
ctx.Exit()
}
if len(cfg.ConfigGroups) == 0 {
ctx.Errorf("at least 1 config_group is required")
return
}
knownNames := make(stringset.Set, len(cfg.ConfigGroups))
fallbackGroupIdx := -1
for i, g := range cfg.ConfigGroups {
ctx.Enter("config_group #%d", i+1)
validateConfigGroup(ctx, g, knownNames)
switch {
case g.Fallback == v2.Toggle_YES && fallbackGroupIdx == -1:
fallbackGroupIdx = i
case g.Fallback == v2.Toggle_YES:
ctx.Errorf("At most 1 config_group with fallback=YES allowed "+
"(already declared in config_group #%d", fallbackGroupIdx+1)
}
ctx.Exit()
}
}
var (
configGroupNameRegexp = regexp.MustCompile("^[a-zA-Z][a-zA-Z0-9_-]{0,39}$")
modeNameRegexp = regexp.MustCompile("^[a-zA-Z][a-zA-Z0-9_-]{0,39}$")
analyzerRun = "ANALYZER_RUN"
standardModes = stringset.NewFromSlice(analyzerRun, "DRY_RUN", "FULL_RUN")
)
func validateConfigGroup(ctx *validation.Context, group *v2.ConfigGroup, knownNames stringset.Set) {
switch {
case group.Name == "":
// TODO(crbug/1063508): make this an error.
ctx.Warningf("please, specify `name` for monitoring and analytics")
case !configGroupNameRegexp.MatchString(group.Name):
// TODO(crbug/1063508): make this an error.
ctx.Warningf("`name` must match %q but %q given", configGroupNameRegexp, group.Name)
case knownNames.Has(group.Name):
ctx.Errorf("duplicate config_group `name` %q not allowed", group.Name)
default:
knownNames.Add(group.Name)
}
if len(group.Gerrit) == 0 {
ctx.Errorf("at least 1 gerrit is required")
}
gerritURLs := stringset.Set{}
for i, g := range group.Gerrit {
ctx.Enter("gerrit #%d", i+1)
validateGerrit(ctx, g)
if g.Url != "" && !gerritURLs.Add(g.Url) {
ctx.Errorf("duplicate gerrit url in the same config_group: %q", g.Url)
}
ctx.Exit()
}
if group.CombineCls != nil {
ctx.Enter("combine_cls")
if group.CombineCls.StabilizationDelay == nil {
ctx.Errorf("stabilization_delay is required to enable cl_grouping")
} else {
switch d, err := ptypes.Duration(group.CombineCls.StabilizationDelay); {
case err != nil:
ctx.Errorf("invalid stabilization_delay: %s", err)
case d.Seconds() < 10.0:
ctx.Errorf("stabilization_delay must be at least 10 seconds")
}
}
if group.GetVerifiers().GetGerritCqAbility().GetAllowSubmitWithOpenDeps() {
ctx.Errorf("combine_cls can not be used with gerrit_cq_ability.allow_submit_with_open_deps=true.")
}
ctx.Exit()
}
additionalModes := stringset.New(len(group.AdditionalModes))
if len(group.AdditionalModes) > 0 {
ctx.Enter("additional_modes")
for _, m := range group.AdditionalModes {
switch name := m.Name; {
case name == "":
ctx.Errorf("`name` is required")
case name == "DRY_RUN" || name == "FULL_RUN":
ctx.Errorf("`name` MUST not be DRY_RUN or FULL_RUN")
case !modeNameRegexp.MatchString(name):
ctx.Errorf("`name` must match %q but %q is given", modeNameRegexp, name)
case additionalModes.Has(name):
ctx.Errorf("duplicate `name` %q not allowed", name)
default:
additionalModes.Add(name)
}
if val := m.CqLabelValue; val < 1 || val > 2 {
ctx.Errorf("`cq_label_value` must be either 1 or 2, got %d", val)
}
switch m.TriggeringLabel {
case "":
ctx.Errorf("`triggering_label` is required")
case "Commit-Queue":
ctx.Errorf("`triggering_label` MUST not be \"Commit-Queue\"")
}
if m.TriggeringValue <= 0 {
ctx.Errorf("`triggering_value` must be > 0")
}
}
ctx.Exit()
}
if group.Verifiers == nil {
ctx.Errorf("verifiers are required")
} else {
ctx.Enter("verifiers")
validateVerifiers(ctx, group.Verifiers, additionalModes.Union(standardModes))
ctx.Exit()
}
}
func validateGerrit(ctx *validation.Context, g *v2.ConfigGroup_Gerrit) {
validateGerritURL(ctx, g.Url)
if len(g.Projects) == 0 {
ctx.Errorf("at least 1 project is required")
}
nameToIndex := make(map[string]int, len(g.Projects))
for i, p := range g.Projects {
ctx.Enter("projects #%d", i+1)
validateGerritProject(ctx, p)
if p.Name != "" {
if _, dup := nameToIndex[p.Name]; !dup {
nameToIndex[p.Name] = i
} else {
ctx.Errorf("duplicate project in the same gerrit: %q", p.Name)
}
}
ctx.Exit()
}
}
func validateGerritURL(ctx *validation.Context, gURL string) {
if gURL == "" {
ctx.Errorf("url is required")
return
}
u, err := url.Parse(gURL)
if err != nil {
ctx.Errorf("failed to parse url %q: %s", gURL, err)
return
}
if u.Path != "" {
ctx.Errorf("path component not yet allowed in url (%q specified)", u.Path)
}
if u.RawQuery != "" {
ctx.Errorf("query component not allowed in url (%q specified)", u.RawQuery)
}
if u.Fragment != "" {
ctx.Errorf("fragment component not allowed in url (%q specified)", u.Fragment)
}
if u.Scheme != "https" {
ctx.Errorf("only 'https' scheme supported for now (%q specified)", u.Scheme)
}
if !strings.HasSuffix(u.Host, ".googlesource.com") {
// TODO(tandrii): relax this.
ctx.Errorf("only *.googlesource.com hosts supported for now (%q specified)", u.Host)
}
}
func validateGerritProject(ctx *validation.Context, gp *v2.ConfigGroup_Gerrit_Project) {
if gp.Name == "" {
ctx.Errorf("name is required")
} else {
if strings.HasPrefix(gp.Name, "/") || strings.HasPrefix(gp.Name, "a/") {
ctx.Errorf("name must not start with '/' or 'a/'")
}
if strings.HasSuffix(gp.Name, "/") || strings.HasSuffix(gp.Name, ".git") {
ctx.Errorf("name must not end with '.git' or '/'")
}
}
regexps := stringset.Set{}
for i, r := range gp.RefRegexp {
ctx.Enter("ref_regexp #%d", i+1)
if _, err := regexp.Compile(r); err != nil {
ctx.Error(err)
}
if !regexps.Add(r) {
ctx.Errorf("duplicate regexp: %q", r)
}
ctx.Exit()
}
for i, r := range gp.RefRegexpExclude {
ctx.Enter("ref_regexp_exclude #%d", i+1)
if _, err := regexp.Compile(r); err != nil {
ctx.Error(err)
}
if !regexps.Add(r) {
// There is no point excluding exact same regexp as including.
ctx.Errorf("duplicate regexp: %q", r)
}
ctx.Exit()
}
}
func validateVerifiers(ctx *validation.Context, v *v2.Verifiers, supportedModes stringset.Set) {
if v.Cqlinter != nil {
ctx.Errorf("cqlinter verifier is not allowed (internal use only)")
}
if v.Fake != nil {
ctx.Errorf("fake verifier is not allowed (internal use only)")
}
if v.TreeStatus != nil {
ctx.Enter("tree_status")
if v.TreeStatus.Url == "" {
ctx.Errorf("url is required")
} else {
switch u, err := url.Parse(v.TreeStatus.Url); {
case err != nil:
ctx.Errorf("failed to parse url %q: %s", v.TreeStatus.Url, err)
case u.Scheme != "https":
ctx.Errorf("url scheme must be 'https'")
}
}
ctx.Exit()
}
if v.GerritCqAbility == nil {
ctx.Errorf("gerrit_cq_ability verifier is required")
} else {
ctx.Enter("gerrit_cq_ability")
if len(v.GerritCqAbility.CommitterList) == 0 {
ctx.Errorf("committer_list is required")
} else {
for i, l := range v.GerritCqAbility.CommitterList {
if l == "" {
ctx.Enter("committer_list #%d", i+1)
ctx.Errorf("must not be empty string")
ctx.Exit()
}
}
}
for i, l := range v.GerritCqAbility.DryRunAccessList {
if l == "" {
ctx.Enter("dry_run_access_list #%d", i+1)
ctx.Errorf("must not be empty string")
ctx.Exit()
}
}
ctx.Exit()
}
if v.Tryjob != nil {
ctx.Enter("tryjob")
validateTryjobVerifier(ctx, v.Tryjob, supportedModes)
ctx.Exit()
}
}
func validateTryjobVerifier(ctx *validation.Context, v *v2.Verifiers_Tryjob, supportedModes stringset.Set) {
if v.RetryConfig != nil {
ctx.Enter("retry_config")
validateTryjobRetry(ctx, v.RetryConfig)
ctx.Exit()
}
switch v.CancelStaleTryjobs {
case v2.Toggle_YES:
ctx.Errorf("`cancel_stale_tryjobs: YES` matches default CQ behavior now; please remove")
case v2.Toggle_NO:
ctx.Errorf("`cancel_stale_tryjobs: NO` is no longer supported, use per-builder `cancel_stale` instead")
case v2.Toggle_UNSET:
// OK
}
if len(v.Builders) == 0 {
ctx.Errorf("at least 1 builder required")
return
}
// Validation of builders is done in two passes: local and global.
visitBuilders := func(cb func(b *v2.Verifiers_Tryjob_Builder)) {
for i, b := range v.Builders {
if b.Name != "" {
ctx.Enter("builder %s", b.Name)
} else {
ctx.Enter("builder #%d", i+1)
}
cb(b)
ctx.Exit()
}
}
// Pass 1, local: verify each builder separately.
// Also, populate data structures for second pass.
names := stringset.Set{}
equi := stringset.Set{} // equivalent_to builder names.
// Subset of builders that can be triggered directly
// and which can be relied upon to trigger other builders.
canStartTriggeringTree := make([]string, 0, len(v.Builders))
triggersMap := map[string][]string{} // who triggers whom.
// Find config by name.
cfgByName := make(map[string]*v2.Verifiers_Tryjob_Builder, len(v.Builders))
hasNonAnalyzerBuilder := false
visitBuilders(func(b *v2.Verifiers_Tryjob_Builder) {
validateBuilderName(ctx, b.Name, names)
cfgByName[b.Name] = b
if b.TriggeredBy != "" {
// Don't validate TriggeredBy as builder name, it should just match
// another main builder name, which will be validated anyway.
triggersMap[b.TriggeredBy] = append(triggersMap[b.TriggeredBy], b.Name)
if b.ExperimentPercentage != 0 {
ctx.Errorf("experiment_percentage is not combinable with triggered_by")
}
if b.EquivalentTo != nil {
ctx.Errorf("equivalent_to is not combinable with triggered_by")
}
}
if b.EquivalentTo != nil {
validateEquivalentBuilder(ctx, b.EquivalentTo, equi)
if b.ExperimentPercentage != 0 {
ctx.Errorf("experiment_percentage is not combinable with equivalent_to")
}
}
if b.ExperimentPercentage != 0 {
if b.ExperimentPercentage < 0.0 || b.ExperimentPercentage > 100.0 {
ctx.Errorf("experiment_percentage must between 0 and 100 (%f given)", b.ExperimentPercentage)
}
if b.IncludableOnly {
ctx.Errorf("includable_only is not combinable with experiment_percentage")
}
}
if len(b.LocationRegexp)+len(b.LocationRegexpExclude) > 0 {
validateRegexp(ctx, "location_regexp", b.LocationRegexp)
validateRegexp(ctx, "location_regexp_exclude", b.LocationRegexpExclude)
if b.IncludableOnly {
ctx.Errorf("includable_only is not combinable with location_regexp[_exclude]")
}
}
if len(b.OwnerWhitelistGroup) > 0 {
for i, g := range b.OwnerWhitelistGroup {
if g == "" {
ctx.Enter("owner_whitelist_group #%d", i+1)
ctx.Errorf("must not be empty string")
ctx.Exit()
}
}
}
var isAnalyzer bool
if len(b.ModeAllowlist) > 0 {
for i, m := range b.ModeAllowlist {
switch {
case !supportedModes.Has(m):
ctx.Enter("mode_allowlist #%d", i+1)
ctx.Errorf("must be one of %s", supportedModes.ToSortedSlice())
ctx.Exit()
case m == analyzerRun:
isAnalyzer = true
}
}
if isAnalyzer {
// TODO(crbug/1202952): Remove following restrictions after Tricium is
// folded into CV.
if len(b.ModeAllowlist) > 1 {
ctx.Errorf("%s must be the only element in mode_allowlist", analyzerRun)
}
for i, r := range b.LocationRegexp {
if !strings.HasPrefix(r, `.+\.`) {
ctx.Enter("location_regexp #%d", i+1)
ctx.Errorf(`location_regexp must start with ".+\." for tryjob run in %s mode`, analyzerRun)
ctx.Exit()
}
}
if len(b.LocationRegexpExclude) > 0 {
ctx.Errorf("location_regexp_exclude is not combinable with tryjob run in %s mode", analyzerRun)
}
}
// TODO(crbug/1191855): See if CV should loose the following restrictions.
if b.TriggeredBy != "" {
ctx.Errorf("triggered_by is not combinable with mode_allowlist")
}
if b.IncludableOnly {
ctx.Errorf("includable_only is not combinable with mode_allowlist")
}
}
if !isAnalyzer {
hasNonAnalyzerBuilder = true
}
if b.ExperimentPercentage == 0 && b.TriggeredBy == "" && b.EquivalentTo == nil {
canStartTriggeringTree = append(canStartTriggeringTree, b.Name)
}
})
if !hasNonAnalyzerBuilder {
// TODO(crbug/1202952): This is for preventing users from defining new
// config group purely for analyzer purpose that accidentally overlaps
// with users' main cq config group before Tricium is merged into CV.
// Current known use cases (i.e. defining Tricium config in auxillary LUCI
// Projects) will set `lucicfg.config(tracked_files="tricium-prod.cfg")`
// so that no cq config will be generated. This check should be removed
// after Tricium is merged into CV so that ANALYZER_RUN is treated the
// same way as any other modes supported by CV.
ctx.Errorf("must have at least one non-analyzer tryjob builder")
}
// Between passes, do a depth-first search into triggers-whom DAG starting
// with only those builders which can be triggered directly by CQ.
q := canStartTriggeringTree
canBeTriggered := stringset.NewFromSlice(q...)
for len(q) > 0 {
var b string
q, b = q[:len(q)-1], q[len(q)-1]
for _, whom := range triggersMap[b] {
if canBeTriggered.Add(whom) {
q = append(q, whom)
} else {
panic("IMPOSSIBLE: builder |b| starting at |canStartTriggeringTree| " +
"isn't triggered by anyone, so it can't be equal to |whom|, which had triggered_by.")
}
}
}
// Corollary: all builders with triggered_by but not in canBeTriggered set
// are not properly configured, either referring to non-existing builder OR
// forming a loop.
// Pass 2, global: verify builder relationships.
visitBuilders(func(b *v2.Verifiers_Tryjob_Builder) {
switch {
case b.EquivalentTo != nil && b.EquivalentTo.Name != "" && names.Has(b.EquivalentTo.Name):
ctx.Errorf("equivalent_to.name must not refer to already defined %q builder", b.EquivalentTo.Name)
case b.TriggeredBy != "" && !names.Has(b.TriggeredBy):
ctx.Errorf("triggered_by must refer to an existing builder, but %q given", b.TriggeredBy)
case b.TriggeredBy != "" && !canBeTriggered.Has(b.TriggeredBy):
// Although we can detect actual loops and emit better errors,
// this happens so rarely, it's not yet worth the time.
ctx.Errorf("triggered_by must refer to an existing builder without "+
"equivalent_to or experiment_percentage options. triggered_by "+
"relationships must also not form a loop (given: %q)",
b.TriggeredBy)
case b.TriggeredBy != "":
// Reaching here means parent exists in config.
parent, _ := cfgByName[b.TriggeredBy]
validateParentLocationRegexp(ctx, b, parent)
}
})
}
func validateBuilderName(ctx *validation.Context, name string, knownNames stringset.Set) {
if name == "" {
ctx.Errorf("name is required")
return
}
if !knownNames.Add(name) {
ctx.Errorf("duplicate name %q", name)
}
parts := strings.Split(name, "/")
if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
ctx.Errorf("name %q doesn't match required format project/short-bucket-name/builder, e.g. 'v8/try/linux'", name)
}
for _, part := range parts {
subs := strings.Split(part, ".")
if len(subs) >= 3 && subs[0] == "luci" {
// Technically, this is allowed. However, practically, this is
// extremely likely to be misunderstanding of project or bucket is.
ctx.Errorf("name %q is highly likely malformed; it should be project/short-bucket-name/builder, e.g. 'v8/try/linux'", name)
return
}
}
if err := lc.ValidateProjectName(parts[0]); err != nil {
ctx.Errorf("first part of %q is not a valid LUCI project name", name)
}
}
func validateEquivalentBuilder(ctx *validation.Context, b *v2.Verifiers_Tryjob_EquivalentBuilder, equiNames stringset.Set) {
ctx.Enter("equivalent_to")
defer ctx.Exit()
validateBuilderName(ctx, b.Name, equiNames)
if b.Percentage < 0 || b.Percentage > 100 {
ctx.Errorf("percentage must be between 0 and 100 (%f given)", b.Percentage)
}
}
func validateRegexp(ctx *validation.Context, field string, values []string) {
valid := stringset.New(len(values))
for i, v := range values {
if v == "" {
ctx.Errorf("%s #%d: must not be empty", field, i+1)
} else if _, err := regexp.Compile(v); err != nil {
ctx.Errorf("%s %q: %s", field, v, err)
} else if !valid.Add(v) {
ctx.Errorf("duplicate %s: %q", field, v)
}
}
}
func validateParentLocationRegexp(ctx *validation.Context, child, parent *v2.Verifiers_Tryjob_Builder) {
// Child's regexps shouldn't be less restrictive than parent.
// While general check is not possible, in known so far use-cases, ensuring
// the regexps are exact same expressions suffices and will prevent
// accidentally incorrect configs.
c := stringset.NewFromSlice(child.LocationRegexp...)
p := stringset.NewFromSlice(parent.LocationRegexp...)
if !p.Contains(c) {
// This func is called in the context of a child.
ctx.Errorf("location_regexp of a triggered builder must be a subset of its parent %q,"+
" but these are not in parent: %s",
parent.Name, strings.Join(c.Difference(p).ToSortedSlice(), ", "))
}
c = stringset.NewFromSlice(child.LocationRegexpExclude...)
p = stringset.NewFromSlice(parent.LocationRegexpExclude...)
if !c.Contains(p) {
// This func is called in the context of a child.
ctx.Errorf("location_regexp_exclude of a triggered builder must contain all those of its parent %q,"+
" but these are only in parent: %s",
parent.Name, strings.Join(p.Difference(c).ToSortedSlice(), ", "))
}
}
func validateTryjobRetry(ctx *validation.Context, r *v2.Verifiers_Tryjob_RetryConfig) {
if r.SingleQuota < 0 {
ctx.Errorf("negative single_quota not allowed (%d given)", r.SingleQuota)
}
if r.GlobalQuota < 0 {
ctx.Errorf("negative global_quota not allowed (%d given)", r.GlobalQuota)
}
if r.FailureWeight < 0 {
ctx.Errorf("negative failure_weight not allowed (%d given)", r.FailureWeight)
}
if r.TransientFailureWeight < 0 {
ctx.Errorf("negative transitive_failure_weight not allowed (%d given)", r.TransientFailureWeight)
}
if r.TimeoutWeight < 0 {
ctx.Errorf("negative timeout_weight not allowed (%d given)", r.TimeoutWeight)
}
}
// validateMigrationSettings validates a migration-settings file.
//
// Validation result is returned via validation ctx, while error returned
// directly implies only a bug in this code.
func validateMigrationSettings(ctx *validation.Context, configSet, path string, content []byte) error {
ctx.SetFile(path)
cfg := migrationpb.Settings{}
if err := proto.UnmarshalText(string(content), &cfg); err != nil {
ctx.Error(err)
return nil
}
if cfg.GetPssaMigration() != nil {
ctx.Errorf("pssa_migration not allowed")
}
for i, a := range cfg.GetApiHosts() {
ctx.Enter("api_hosts #%d", i+1)
switch h := a.GetHost(); h {
case "luci-change-verifier-dev.appspot.com":
case "luci-change-verifier.appspot.com":
default:
ctx.Errorf("invalid host (given: %q)", h)
}
validateRegexp(ctx, "project_regexp", a.GetProjectRegexp())
validateRegexp(ctx, "project_regexp_exclude", a.GetProjectRegexpExclude())
ctx.Exit()
}
if u := cfg.GetUseCvRuns(); u != nil {
validateRegexp(ctx, "project_regexp", u.GetProjectRegexp())
validateRegexp(ctx, "project_regexp_exclude", u.GetProjectRegexpExclude())
}
return nil
}