blob: 9dfb84e93741d5105ace04a819080c0eda4ba1aa [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
import (
"testing"
"github.com/golang/protobuf/proto"
"go.chromium.org/luci/appengine/gaetesting"
"go.chromium.org/luci/config/validation"
v2 "go.chromium.org/luci/cq/api/config/v2"
. "github.com/smartystreets/goconvey/convey"
. "go.chromium.org/luci/common/testing/assertions"
)
func TestValidationRules(t *testing.T) {
t.Parallel()
Convey("Validation Rules", t, func() {
c := gaetesting.TestingContextWithAppID("commit-queue")
patterns, err := validation.Rules.ConfigPatterns(c)
So(err, ShouldBeNil)
So(len(patterns), ShouldEqual, 2)
Convey("project-scope cq.cfg", func() {
So(patterns[0].ConfigSet.Match("projects/xyz"), ShouldBeTrue)
So(patterns[0].ConfigSet.Match("projects/xyz/refs/heads/master"), ShouldBeFalse)
So(patterns[0].Path.Match("commit-queue.cfg"), ShouldBeTrue)
})
Convey("legacy ref-scope cq.cfg", func() {
So(patterns[1].ConfigSet.Match("projects/xyz"), ShouldBeFalse)
So(patterns[1].ConfigSet.Match("projects/xyz/refs/heads/master"), ShouldBeTrue)
So(patterns[1].Path.Match("cq.cfg"), ShouldBeTrue)
})
})
}
func TestValidationLegacy(t *testing.T) {
t.Parallel()
Convey("Validate Legacy Config", t, func() {
c := gaetesting.TestingContextWithAppID("commit-queue")
vctx := &validation.Context{Context: c}
configSet := "projects/foo/refs/heads/master"
path := "cq.cfg"
Convey("Loading any config", func() {
So(validateRef(vctx, configSet, path, []byte(`any!`)), ShouldBeNil)
err := vctx.Finalize()
So(err, ShouldErrLike, "delete cq.cfg")
})
})
}
const validConfigTextPB = `
draining_start_time: "2017-12-23T15:47:58Z"
cq_status_host: "example.com"
submit_options {
max_burst: 2
burst_delay { seconds: 120 }
}
config_groups {
gerrit {
url: "https://chromium-review.googlesource.com"
projects {
name: "chromium/src"
}
}
verifiers {
tree_status { url: "https://chromium-status.appspot.com" }
gerrit_cq_ability { committer_list: "project-chromium-committers" }
tryjob {
retry_config {
single_quota: 1
global_quota: 2
failure_weight: 1
transient_failure_weight: 1
timeout_weight: 1
}
builders { name: "chromium/try/linux" }
}
}
}
`
func TestValidation(t *testing.T) {
t.Parallel()
Convey("Validate Config", t, func() {
c := gaetesting.TestingContext()
vctx := &validation.Context{Context: c}
configSet := "projects/foo"
path := "cq.cfg"
Convey("Loading bad proto", func() {
content := []byte(` bad: "config" `)
So(validateProject(vctx, configSet, path, content), ShouldBeNil)
So(vctx.Finalize().Error(), ShouldContainSubstring, "unknown field")
})
// It's easier to manipulate Go struct than text.
cfg := v2.Config{}
So(proto.UnmarshalText(validConfigTextPB, &cfg), ShouldBeNil)
Convey("OK", func() {
Convey("good proto, good config", func() {
So(validateProject(vctx, configSet, path, []byte(validConfigTextPB)), ShouldBeNil)
So(vctx.Finalize(), ShouldBeNil)
})
Convey("good config", func() {
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldBeNil)
})
})
Convey("Top-level config", func() {
Convey("Top level opts can be omitted", func() {
cfg.DrainingStartTime = ""
cfg.CqStatusHost = ""
cfg.SubmitOptions = nil
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldBeNil)
})
Convey("Bad draining time", func() {
cfg.DrainingStartTime = "meh"
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, `failed to parse draining_start_time "meh" as RFC3339 format`)
})
Convey("Bad cq_status_host", func() {
cfg.CqStatusHost = "h://@test:123//not//://@adsfhost."
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldNotBeNil)
})
Convey("cq_status_host not just host", func() {
cfg.CqStatusHost = "example.com/path#fragment"
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, `should be just a host "example.com"`)
})
Convey("Bad max_burst", func() {
cfg.SubmitOptions.MaxBurst = -1
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldNotBeNil)
})
Convey("Bad burst_delay ", func() {
cfg.SubmitOptions.BurstDelay.Seconds = -1
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldNotBeNil)
})
Convey("at least 1 Config Group", func() {
cfg.ConfigGroups = nil
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "at least 1 config_group is required")
})
Convey("no obviously overlaping config_groups", func() {
cfg.ConfigGroups = append(cfg.ConfigGroups, cfg.ConfigGroups[0])
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "aliases config_group #1")
})
Convey("2nd heuristic against overlaping config_groups", func() {
// Store original valid first and only ConfigGroup.
orig := cfg.ConfigGroups[0]
cfg.ConfigGroups = nil
add := func(refRegexps ...string) {
// Add new regexps sequence with constant valid gerrit url and project and
// the same valid verifers.
cfg.ConfigGroups = append(cfg.ConfigGroups, &v2.ConfigGroup{
Gerrit: []*v2.ConfigGroup_Gerrit{
{
Url: orig.Gerrit[0].Url,
Projects: []*v2.ConfigGroup_Gerrit_Project{
{
Name: orig.Gerrit[0].Projects[0].Name,
RefRegexp: refRegexps,
},
},
},
},
Verifiers: orig.Verifiers,
})
}
Convey("infra/config", func() {
add("refs/heads/infra/config")
add("refs/.+")
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, `ref "refs/heads/infra/config" matches config_groups [0 1]`)
})
Convey("master", func() {
add() // default, meaning refs/heads/master.
add("refs/branch-heads/.+")
add("refs/heads/.+")
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, `ref "refs/heads/master" matches config_groups [0 2]`)
})
})
})
Convey("ConfiGroups", func() {
Convey("with Gerrit", func() {
cfg.ConfigGroups[0].Gerrit = nil
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "at least 1 gerrit is required")
})
Convey("with Verifiers", func() {
cfg.ConfigGroups[0].Verifiers = nil
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "verifiers are required")
})
Convey("no dup Gerrit blocks", func() {
cfg.ConfigGroups[0].Gerrit = append(cfg.ConfigGroups[0].Gerrit, cfg.ConfigGroups[0].Gerrit[0])
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "duplicate gerrit url in the same config_group")
})
})
Convey("Gerrit", func() {
g := cfg.ConfigGroups[0].Gerrit[0]
Convey("needs valid URL", func() {
g.Url = ""
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "url is required")
g.Url = ":badscheme, bad URL"
vctx = &validation.Context{Context: c}
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "failed to parse url")
})
Convey("without fancy URL components", func() {
g.Url = "bad://ok/path-not-good?query=too#neither-is-fragment"
validateProjectConfig(vctx, &cfg)
err := vctx.Finalize()
So(err, ShouldErrLike, "path component not yet allowed in url")
So(err, ShouldErrLike, "and 4 other errors")
})
Convey("current limitations", func() {
g.Url = "https://not.yet.allowed.com"
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "only *.googlesource.com hosts supported for now")
vctx = &validation.Context{Context: c}
g.Url = "new-scheme://chromium-review.googlesource.com"
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "only 'https' scheme supported for now")
})
Convey("at least 1 project required", func() {
g.Projects = nil
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "at least 1 project is required")
})
Convey("no dup project blocks", func() {
g.Projects = append(g.Projects, g.Projects[0])
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "duplicate project in the same gerrit")
})
})
Convey("Gerrit Project", func() {
p := cfg.ConfigGroups[0].Gerrit[0].Projects[0]
Convey("project name required", func() {
p.Name = ""
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "name is required")
})
Convey("incorrect project names", func() {
p.Name = "a/prefix-not-allowed/so-is-.git-suffix/.git"
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldNotBeNil)
vctx = &validation.Context{Context: c}
p.Name = "/prefix-not-allowed/so-is-/-suffix/"
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldNotBeNil)
})
Convey("bad regexp", func() {
p.RefRegexp = []string{"refs/heads/master", "*is-bad-regexp"}
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "ref_regexp #2): error parsing regexp:")
})
Convey("duplicate regexp", func() {
p.RefRegexp = []string{"refs/heads/master", "refs/heads/master"}
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "ref_regexp #2): duplicate regexp:")
})
})
Convey("Verifiers", func() {
v := cfg.ConfigGroups[0].Verifiers
Convey("fake not allowed", func() {
v.Fake = &v2.Verifiers_Fake{}
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "fake verifier is not allowed")
})
Convey("deprecator not allowed", func() {
v.Cqlinter = &v2.Verifiers_CQLinter{}
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "cqlinter verifier is not allowed")
})
Convey("tree_status", func() {
v.TreeStatus = &v2.Verifiers_TreeStatus{}
Convey("needs URL", func() {
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "url is required")
})
Convey("needs https URL", func() {
v.TreeStatus.Url = "http://example.com/test"
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "url scheme must be 'https'")
})
})
Convey("gerrit_cq_ability", func() {
Convey("is required", func() {
v.GerritCqAbility = nil
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "gerrit_cq_ability verifier is required")
})
Convey("needs committer_list", func() {
v.GerritCqAbility.CommitterList = nil
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "committer_list is required")
})
Convey("no empty committer_list", func() {
v.GerritCqAbility.CommitterList = []string{""}
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "must not be empty")
})
Convey("no empty dry_run_access_list", func() {
v.GerritCqAbility.DryRunAccessList = []string{""}
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike, "must not be empty")
})
})
})
Convey("Tryjob", func() {
v := cfg.ConfigGroups[0].Verifiers.Tryjob
Convey("really bad retry config", func() {
v.RetryConfig.SingleQuota = -1
v.RetryConfig.GlobalQuota = -1
v.RetryConfig.FailureWeight = -1
v.RetryConfig.TransientFailureWeight = -1
v.RetryConfig.TimeoutWeight = -1
validateProjectConfig(vctx, &cfg)
So(vctx.Finalize(), ShouldErrLike,
"negative single_quota not allowed (-1 given) (and 4 other errors)")
})
})
})
}
func TestTryjobValidation(t *testing.T) {
t.Parallel()
Convey("Validate Tryjob Verifier Config", t, func() {
c := gaetesting.TestingContext()
validate := func(textPB string) error {
vctx := &validation.Context{Context: c}
cfg := v2.Verifiers_Tryjob{}
if err := proto.UnmarshalText(textPB, &cfg); err != nil {
panic(err)
}
validateTryjobVerifier(vctx, &cfg)
return vctx.Finalize()
}
So(validate(``), ShouldErrLike, "at least 1 builder required")
Convey("builder name", func() {
So(validate(`builders {}`), ShouldErrLike, "name is required")
So(validate(`builders {name: ""}`), ShouldErrLike, "name is required")
So(validate(`builders {name: "a"}`), ShouldErrLike,
`name "a" doesn't match required format`)
So(validate(`builders {name: "a/b/c" equivalent_to {name: "z"}}`), ShouldErrLike,
`name "z" doesn't match required format`)
So(validate(`builders {name: "b/luci.b.try/c"}`), ShouldErrLike,
`name "b/luci.b.try/c" is highly likely malformed;`)
So(validate(`
builders {name: "a/b/c"}
builders {name: "a/b/c"}
`), ShouldErrLike, "duplicate")
So(validate(`
builders {name: "*/buildbot/b"}
builders {name: "a/b/c" equivalent_to {name: "x/y/z"}}
`), ShouldBeNil)
})
Convey("experiment", func() {
So(validate(`builders {name: "a/b/c" experiment_percentage: 1}`), ShouldBeNil)
So(validate(`builders {name: "a/b/c" experiment_percentage: -1}`), ShouldNotBeNil)
So(validate(`builders {name: "a/b/c" experiment_percentage: 101}`), ShouldNotBeNil)
})
Convey("location_regexps", func() {
So(validate(`builders {name: "a/b/c" location_regexp: ""}`),
ShouldErrLike, "must not be empty")
So(validate(`builders {name: "a/b/c" location_regexp_exclude: "*"}`),
ShouldErrLike, "error parsing regexp: missing argument")
So(validate(`
builders {
name: "a/b/c"
location_regexp: ".+"
location_regexp: ".+"
}`), ShouldErrLike, "duplicate")
So(validate(`
builders {
name: "a/b/c"
location_regexp: "a/.+"
location_regexp: "b"
location_regexp_exclude: "redundant/but/not/caught"
}`), ShouldBeNil)
})
Convey("equivalent_to", func() {
So(validate(`
builders {
name: "a/b/c"
equivalent_to {name: "x/y/z" percentage: 10 owner_whitelist_group: "group"}
}`),
ShouldBeNil)
So(validate(`
builders {
name: "a/b/c"
equivalent_to {name: "x/y/z" percentage: -1 owner_whitelist_group: "group"}
}`),
ShouldErrLike, "percentage must be between 0 and 100")
So(validate(`
builders {
name: "a/b/c"
equivalent_to {name: "a/b/c"}
}`),
ShouldErrLike,
`equivalent_to.name must not refer to already defined "a/b/c" builder`)
So(validate(`
builders {
name: "a/b/c"
equivalent_to {name: "c/d/e"}
}
builders {
name: "x/y/z"
equivalent_to {name: "c/d/e"}
}`),
ShouldErrLike,
`duplicate name "c/d/e"`)
})
Convey("no combinations", func() {
So(validate(`
builders {
name: "a/b/c"
experiment_percentage: 1
equivalent_to {name: "c/d/e"}}`),
ShouldErrLike,
"combining [equivalent_to experiment_percentage] features not allowed")
So(validate(`
builders {
name: "a/b/c"
location_regexp: ".+"
triggered_by: "c/d/e"
}
builders { name: "c/d/e" } `),
ShouldErrLike,
"combining [triggered_by location_regexp[_exclude]] features not allowed")
})
Convey("triggered_by", func() {
So(validate(`
builders {name: "a/b/0" }
builders {name: "a/b/1" triggered_by: "a/b/0"}
builders {name: "a/b/21" triggered_by: "a/b/1"}
builders {name: "a/b/22" triggered_by: "a/b/1"}
`), ShouldBeNil)
So(validate(`builders {name: "a/b/1" triggered_by: "a/b/0"}`),
ShouldErrLike, `triggered_by must refer to an existing builder, but "a/b/0" given`)
So(validate(`
builders {name: "a/b/0" experiment_percentage: 10}
builders {name: "a/b/1" triggered_by: "a/b/0"}
`), ShouldErrLike,
`builder a/b/1): triggered_by must refer to an existing builder without`)
Convey("doesn't form loops", func() {
So(validate(`
builders {name: "l/oo/p" triggered_by: "l/oo/p"}
`), ShouldErrLike, `triggered_by must refer to an existing builder without`)
So(validate(`
builders {name: "tri/gger/able"}
builders {name: "l/oo/p1" triggered_by: "l/oo/p2"}
builders {name: "l/oo/p2" triggered_by: "l/oo/p1"}
`), ShouldErrLike, `triggered_by must refer to an existing builder without`)
})
})
})
}